mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93743bf04c | ||
|
|
32aba18c38 | ||
|
|
9178199073 | ||
|
|
48a59f18bc | ||
|
|
a5be38700c | ||
|
|
4cc52933e0 | ||
|
|
d126797e7d | ||
|
|
b310e7610b | ||
|
|
886cab7960 | ||
|
|
33e64eefed | ||
|
|
dd53d5d79e | ||
|
|
fea1d664f1 | ||
|
|
0c020ba4c6 | ||
|
|
c0bda7ce57 | ||
|
|
d640d4cb67 | ||
|
|
263520bf4a | ||
|
|
769f3f5188 | ||
|
|
f059d71204 | ||
|
|
28f10a99bf | ||
|
|
8a22932713 | ||
|
|
b3ef5c7e8d | ||
|
|
18bc93e1eb | ||
|
|
d897768317 | ||
|
|
c7ebd1cc54 | ||
|
|
e73cc73efc | ||
|
|
ac48cc7f30 | ||
|
|
420e8a8de5 | ||
|
|
303da75b60 | ||
|
|
a4db571df0 | ||
|
|
ede7251fbc | ||
|
|
5c4d5ef176 | ||
|
|
0a2e13b0d1 | ||
|
|
c952530514 | ||
|
|
3351bd42bb | ||
|
|
3c269a6e0d | ||
|
|
2633f4ca01 | ||
|
|
947137992e | ||
|
|
f02390dc77 | ||
|
|
1ffdd6c5f4 | ||
|
|
ae8e9c2c16 | ||
|
|
5485e27dfe | ||
|
|
80ce38491f | ||
|
|
bd49c39ce2 | ||
|
|
938ea40c92 | ||
|
|
dd99968829 | ||
|
|
b87a878cad | ||
|
|
55915d9941 | ||
|
|
d5df0e5ba2 | ||
|
|
8e65d02464 | ||
|
|
c5f8016a36 | ||
|
|
174d344ec7 | ||
|
|
9b7119705d | ||
|
|
76c7fd7d13 | ||
|
|
e6a05ed900 | ||
|
|
b7ba277879 | ||
|
|
405ffa9038 | ||
|
|
c99605ab72 | ||
|
|
649b1b5e97 | ||
|
|
c4ff4cb836 | ||
|
|
163bbe0e1c | ||
|
|
4f07b28434 | ||
|
|
b521e4dd65 | ||
|
|
c692a1e35e | ||
|
|
dea6bce292 | ||
|
|
2fac27c698 | ||
|
|
2e42c195a2 | ||
|
|
2e15724ed6 | ||
|
|
3438a740a8 | ||
|
|
1b89b652fe | ||
|
|
012b519d3a | ||
|
|
223c191aae | ||
|
|
1b36f57358 | ||
|
|
0081d81306 | ||
|
|
c11bb5cd38 | ||
|
|
a182c3cb9a | ||
|
|
3fecc3212d | ||
|
|
bfd7cde9fb | ||
|
|
c722007772 |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
# Every pull request requests review from the maintainers below.
|
||||
# Either maintainer can review and merge any change.
|
||||
* @SivanCola @esengine
|
||||
@@ -50,12 +50,12 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build harness + agent from the default branch
|
||||
# The PR head may predate the bot (no cmd/e2ebench, no `run --metrics`), so
|
||||
# build both here and stash them + the suite outside the tree before the
|
||||
# working tree is switched to the PR head.
|
||||
- name: Build harness + fallback agent from the default branch
|
||||
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
|
||||
# its own grader or tests. The agent is rebuilt from the PR head below; this
|
||||
# main-v2 build is only the fallback for heads that predate `run --metrics`.
|
||||
run: |
|
||||
go build -o "$RUNNER_TEMP/reasonix" ./cmd/reasonix
|
||||
go build -o "$RUNNER_TEMP/reasonix-base" ./cmd/reasonix
|
||||
go build -o "$RUNNER_TEMP/e2ebench" ./cmd/e2ebench
|
||||
cp -r benchmarks/e2e "$RUNNER_TEMP/suite"
|
||||
|
||||
@@ -64,6 +64,23 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh pr checkout ${{ github.event.issue.number }}
|
||||
|
||||
- name: Build the agent from the PR head
|
||||
# The whole point of the bot is to drive the PR's code, not main-v2's. Fall
|
||||
# back to the main-v2 build only when the PR head can't yield a
|
||||
# run --metrics-capable binary (build break or predates the flag).
|
||||
id: agent
|
||||
run: |
|
||||
bin="$RUNNER_TEMP/reasonix-base"
|
||||
src="main-v2 fallback (PR head lacks run --metrics)"
|
||||
if go build -o "$RUNNER_TEMP/reasonix-pr" ./cmd/reasonix \
|
||||
&& "$RUNNER_TEMP/reasonix-pr" run -h 2>&1 | grep -q -- '-metrics'; then
|
||||
bin="$RUNNER_TEMP/reasonix-pr"
|
||||
src="PR head ($(git rev-parse --short HEAD))"
|
||||
fi
|
||||
echo "bin=$bin" >> "$GITHUB_OUTPUT"
|
||||
echo "src=$src" >> "$GITHUB_OUTPUT"
|
||||
echo "agent under test: $src"
|
||||
|
||||
- name: Write provider config
|
||||
# User config covers suite tasks (they run in temp dirs); the repo-root copy
|
||||
# covers diff mode (the agent runs in the repo root, where project config wins).
|
||||
@@ -78,7 +95,7 @@ jobs:
|
||||
base_url = "${{ vars.REASONIX_E2E_BASE_URL || 'https://api.deepseek.com' }}"
|
||||
model = "${{ vars.REASONIX_E2E_MODEL || 'deepseek-v4-flash' }}"
|
||||
api_key_env = "DEEPSEEK_API_KEY"
|
||||
context_window = 65536
|
||||
context_window = 20000 # small so the suite actually exercises compaction + cache churn
|
||||
|
||||
[providers.price]
|
||||
cache_hit = ${{ vars.REASONIX_E2E_PRICE_CACHE_HIT || '0.02' }}
|
||||
@@ -112,9 +129,9 @@ jobs:
|
||||
BASE_REF=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)
|
||||
git fetch -q origin "$BASE_REF"
|
||||
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
|
||||
"$RUNNER_TEMP/e2ebench" -mode diff -bin "$RUNNER_TEMP/reasonix" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
|
||||
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
|
||||
else
|
||||
"$RUNNER_TEMP/e2ebench" -bin "$RUNNER_TEMP/reasonix" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
|
||||
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
|
||||
fi
|
||||
|
||||
- name: Post report
|
||||
@@ -122,7 +139,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TRIGGER_USER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
printf '\n> agent built from main-v2 · PR head %s · triggered by @%s\n' "$(git rev-parse --short HEAD)" "$TRIGGER_USER" >> report.md
|
||||
printf '\n> agent: %s · triggered by @%s\n' "${{ steps.agent.outputs.src }}" "$TRIGGER_USER" >> report.md
|
||||
gh pr comment ${{ github.event.issue.number }} --body-file report.md
|
||||
|
||||
- name: Report failure
|
||||
|
||||
@@ -69,12 +69,13 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
|
||||
# Linux: WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
|
||||
# Linux: WebKitGTK 4.1 toolchain (-tags webkit2_41 in desktop-build.sh).
|
||||
# 4.1 ships from ubuntu-22.04 on and is the only one present on 24.04+/Fedora 40+.
|
||||
- name: Install Linux build deps
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
# Windows: NSIS provides makensis for `wails build -nsis`.
|
||||
- name: Install NSIS
|
||||
@@ -83,6 +84,11 @@ jobs:
|
||||
choco install nsis -y --no-progress
|
||||
echo "C:\Program Files (x86)\NSIS" >> "$GITHUB_PATH"
|
||||
|
||||
# macOS: create-dmg packages the .app into a drag-to-Applications .dmg.
|
||||
- name: Install create-dmg
|
||||
if: runner.os == 'macOS'
|
||||
run: brew install create-dmg
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
|
||||
@@ -54,3 +54,7 @@ node_modules/
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Scratch / agent working dirs (never committed)
|
||||
/tmp/
|
||||
/.codex/
|
||||
@@ -103,7 +103,7 @@ default_model = "deepseek-flash" # executor; set [agent].planner_model to add
|
||||
# planner_model = "mimo-pro" # optional low-frequency planner
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
auto_plan = "ask" # off|ask|on; complex chat tasks start in plan mode
|
||||
auto_plan = "off" # off|on; off keeps plan mode manual
|
||||
# auto_plan_classifier = "deepseek-flash" # optional; only borderline tasks call it
|
||||
|
||||
[[providers]]
|
||||
@@ -247,11 +247,15 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
|
||||
run them on another configured model, or use `subagent_models` to override only
|
||||
specific skills such as `review` or `security_review`.
|
||||
|
||||
For interactive frontends, `agent.auto_plan = "ask"` makes complex-looking tasks
|
||||
enter plan mode automatically: Reasonix first drafts a read-only plan, then waits
|
||||
for approval before editing or running side-effecting commands. `auto_plan_classifier`
|
||||
can name a cheap provider such as `deepseek-flash`; it is only called for
|
||||
borderline inputs and falls back to the heuristic if classification fails.
|
||||
For interactive frontends, plan mode is manual by default. Set
|
||||
`agent.auto_plan = "on"` to make complex-looking tasks enter plan mode
|
||||
automatically: Reasonix first drafts a read-only plan, then waits for approval
|
||||
before editing or running side-effecting commands. `auto_plan_classifier` can
|
||||
name a cheap provider such as `deepseek-flash`; it is only called for borderline
|
||||
inputs and falls back to the heuristic if classification fails. Use
|
||||
`/auto-plan off|on` in `reasonix chat` to change the user-level setting, or
|
||||
`reasonix config auto-plan off|on` from a shell/script. Pass `--local` to the
|
||||
shell command only when you intentionally want a project-local override.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
+8
-4
@@ -99,7 +99,7 @@ default_model = "deepseek-flash" # 执行器;设 [agent].planner_model 可
|
||||
# planner_model = "mimo-pro" # 可选的低频规划器
|
||||
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
auto_plan = "ask" # off|ask|on;复杂聊天任务自动进入计划模式
|
||||
auto_plan = "off" # off|on;off 表示计划模式仅手动开启
|
||||
# auto_plan_classifier = "deepseek-flash" # 可选;只在边界任务上调用
|
||||
|
||||
[[providers]]
|
||||
@@ -227,9 +227,13 @@ planner_model = "deepseek-pro" # 作为低频规划器
|
||||
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
|
||||
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
|
||||
|
||||
交互式前端中,`agent.auto_plan = "ask"` 会让看起来复杂的任务自动进入 plan
|
||||
mode:Reasonix 先只读生成计划,待用户批准后才编辑文件或执行有副作用的命令。
|
||||
`auto_plan_classifier` 可以指定便宜的 provider,例如 `deepseek-flash`;它只在边界输入上调用,分类失败会回退到启发式规则。
|
||||
交互式前端中,计划模式默认手动开启。设置 `agent.auto_plan = "on"` 后,看起来复杂
|
||||
的任务会自动进入 plan mode:Reasonix 先只读生成计划,待用户批准后才
|
||||
编辑文件或执行有副作用的命令。`auto_plan_classifier` 可以指定便宜的 provider,例如
|
||||
`deepseek-flash`;它只在边界输入上调用,分类失败会回退到启发式规则。也可以用
|
||||
`reasonix chat` 里的 `/auto-plan off|on` 修改用户级设置,或在 shell/脚本里用
|
||||
`reasonix config auto-plan off|on`。只有明确想写项目级覆盖时,才给 shell 命令加
|
||||
`--local`。
|
||||
|
||||
## 架构
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
prompt = "Begin by reading the file story/chapter-1.md. Each chapter ends by telling you which chapter to read next; follow that trail one chapter at a time — read a chapter, find where it points you, then read that next chapter — until a chapter says the trail ends. You cannot know the next chapter without reading the current one, so do not guess or read ahead. In chapter 1 a river village is named; in the final chapter on the trail a noble House is named. When the trail ends, write a file answer.txt whose entire contents are the village name from chapter 1, a single hyphen, and the House name from the final chapter — for example Riverton-Stark — and nothing else."
|
||||
max_steps = 40
|
||||
timeout_sec = 900
|
||||
@@ -0,0 +1,4 @@
|
||||
set -e
|
||||
got=$(tr -d '[:space:]' < answer.txt | tr '[:upper:]' '[:lower:]')
|
||||
want="aldermoor-verrin"
|
||||
[ "$got" = "$want" ] || { echo "answer.txt normalized to '$got', want '$want'"; exit 1; }
|
||||
@@ -0,0 +1,220 @@
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 4 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 10 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 16 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 22 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 28 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 34 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 40 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 46 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 52 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 58 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 64 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 70 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 76 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 82 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 88 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 94 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 100 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 106 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
It was from Aldermoor, the cluster of cottages where the north and south rivers braid together, that our courier first set out.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 112 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 118 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 124 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 130 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 136 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 142 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 148 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 154 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 160 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 166 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 172 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 178 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 184 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 190 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 196 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 202 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 208 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
To pick up the trail again, the next page you must read is chapter-4.md.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 214 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 220 of chapter 1: the chronicle continues, patient and unhurried, toward its end.
|
||||
@@ -0,0 +1,220 @@
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 3 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 9 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 15 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 21 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 27 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 33 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 39 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 45 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 51 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 57 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 63 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 69 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 75 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 81 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 87 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 93 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 99 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 105 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 111 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 117 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 123 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 129 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 135 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 141 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 147 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 153 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 159 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 165 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 171 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 177 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 183 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 189 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 195 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 201 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 207 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
To pick up the trail again, the next page you must read is chapter-5.md.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 213 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 219 of chapter 2: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
@@ -0,0 +1,220 @@
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 2 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 8 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 14 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 20 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 26 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 32 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 38 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 44 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 50 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 56 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 62 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 68 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 74 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 80 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 86 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 92 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 98 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 104 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 110 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 116 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 122 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 128 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 134 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 140 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 146 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 152 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 158 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 164 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 170 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 176 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 182 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 188 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 194 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 200 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 206 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
To pick up the trail again, the next page you must read is chapter-6.md.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 212 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 218 of chapter 3: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
@@ -0,0 +1,220 @@
|
||||
Line 1 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 7 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 13 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 19 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 25 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 31 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 37 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 43 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 49 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 55 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 61 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 67 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 73 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 79 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 85 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 91 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 97 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 103 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 109 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 115 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 121 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 127 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 133 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 139 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 145 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 151 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 157 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 163 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 169 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 175 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 181 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 187 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 193 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 199 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 205 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
To pick up the trail again, the next page you must read is chapter-2.md.
|
||||
Line 211 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 217 of chapter 4: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
@@ -0,0 +1,220 @@
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 6 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 12 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 18 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 24 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 30 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 36 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 42 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 48 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 54 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 60 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 66 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 72 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 78 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 84 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 90 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 96 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 102 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 108 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 114 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 120 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 126 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 132 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 138 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 144 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 150 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 156 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 162 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 168 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 174 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 180 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 186 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 192 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 198 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 204 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
To pick up the trail again, the next page you must read is chapter-3.md.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 216 of chapter 5: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
@@ -0,0 +1,220 @@
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 5 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 11 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 17 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 23 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 29 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 35 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 41 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 47 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 53 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 59 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 65 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 71 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 77 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 83 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 89 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 95 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 101 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 107 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
With that, the ancestral seal of the Verrin was pressed into the warm wax and then, before the whole assembly, ceremonially broken.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 113 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 119 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 125 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 131 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 137 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 143 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 149 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 155 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 161 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 167 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 173 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 179 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 185 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 191 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 197 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 203 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 209 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
Here the trail ends; there is no further page to follow. You now hold both names you were sent to find: the river village of Aldermoor, where the courier began in the first chapter, and the House whose seal was broken above. Your task is complete the moment you write a file named answer.txt whose entire contents are that village name, a single hyphen, and that House name, and nothing else. Write it now.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
Line 215 of chapter 6: the chronicle continues, patient and unhurried, toward its end.
|
||||
The road wound on through the grey hills, and the company spoke little of what lay behind them.
|
||||
Rain had come in the night, so the morning was washed and bright, and the carts ran easily.
|
||||
Old stories were traded at the fire, half-remembered and half-invented, as such stories are.
|
||||
A merchant counted his coin twice and still mistrusted the sum, for the season had been lean.
|
||||
Banners of the lesser lords hung limp in the still air above the gatehouse, faded by sun.
|
||||
+96
-10
@@ -9,6 +9,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
@@ -82,6 +83,7 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
|
||||
cmd.Dir = o.repo
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second // bound the wait for a wedged child after ctx timeout
|
||||
runErr := cmd.Run()
|
||||
|
||||
// The agent's new files are untracked, so `git diff HEAD` would miss them;
|
||||
@@ -150,6 +152,7 @@ func resetTree(repo string) {
|
||||
func goBuildAll(repo string) (bool, string) {
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait if `go build` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
@@ -305,10 +308,22 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
|
||||
}
|
||||
}
|
||||
// Restore source even on panic; a tree left on `base` would mask the PR for later steps.
|
||||
restored := false
|
||||
defer func() {
|
||||
if restored {
|
||||
return
|
||||
}
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
}()
|
||||
|
||||
out := make([]pinResult, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a hung test
|
||||
raw, err := cmd.CombinedOutput()
|
||||
out = append(out, pinResult{
|
||||
testRef: r,
|
||||
@@ -319,6 +334,7 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
|
||||
for _, f := range srcFiles {
|
||||
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
|
||||
}
|
||||
restored = true
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -386,6 +402,11 @@ func parseCoverProfile(repo, path string) map[string][]coverBlock {
|
||||
// repoRelFromModulePath turns "reasonix/internal/agent/foo.go" into
|
||||
// "internal/agent/foo.go" by dropping the first path element (the module root).
|
||||
func repoRelFromModulePath(p string) string {
|
||||
// Strip the full module prefix; a generic first-segment cut mis-strips a multi-segment module path.
|
||||
prefix := "reasonix/"
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return p[len(prefix):]
|
||||
}
|
||||
if i := strings.IndexByte(p, '/'); i >= 0 {
|
||||
return p[i+1:]
|
||||
}
|
||||
@@ -409,12 +430,17 @@ func changedLineSet(repo, base string, srcFiles []string) map[string]map[int]boo
|
||||
out[file] = map[int]bool{}
|
||||
case strings.HasPrefix(ln, "@@"):
|
||||
// @@ -a,b +c,d @@ — start collecting at new-side line c.
|
||||
// Digit-only cut: malformed headers (e.g. `@@ +abc @@`) fail closed.
|
||||
if plus := strings.Index(ln, "+"); plus >= 0 {
|
||||
num := ln[plus+1:]
|
||||
if sp := strings.IndexAny(num, ", "); sp >= 0 {
|
||||
num = num[:sp]
|
||||
end := len(num)
|
||||
for i := 0; i < len(num); i++ {
|
||||
if num[i] < '0' || num[i] > '9' {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Sscanf(num, "%d", &newLine)
|
||||
_, _ = fmt.Sscanf(num[:end], "%d", &newLine)
|
||||
}
|
||||
case strings.HasPrefix(ln, "+") && !strings.HasPrefix(ln, "+++"):
|
||||
if file != "" {
|
||||
@@ -464,11 +490,26 @@ func parseNewTests(diff string) []testRef {
|
||||
continue
|
||||
}
|
||||
sig := strings.TrimPrefix(body, "func ")
|
||||
paren := strings.IndexByte(sig, '(')
|
||||
if paren <= 0 {
|
||||
continue
|
||||
// Method form `(r T) Name(...)` starts with '('; parse the receiver out before the name.
|
||||
var name string
|
||||
if sig[0] == '(' {
|
||||
close := strings.IndexByte(sig, ')')
|
||||
if close < 0 {
|
||||
continue
|
||||
}
|
||||
rest := strings.TrimSpace(sig[close+1:])
|
||||
methodParen := strings.IndexByte(rest, '(')
|
||||
if methodParen <= 0 {
|
||||
continue
|
||||
}
|
||||
name = rest[:methodParen]
|
||||
} else {
|
||||
funcParen := strings.IndexByte(sig, '(')
|
||||
if funcParen <= 0 {
|
||||
continue
|
||||
}
|
||||
name = sig[:funcParen]
|
||||
}
|
||||
name := sig[:paren]
|
||||
if strings.HasPrefix(name, "Test") || strings.HasPrefix(name, "Fuzz") || strings.HasPrefix(name, "Benchmark") {
|
||||
refs = append(refs, testRef{name: name, pkg: pkg})
|
||||
}
|
||||
@@ -505,17 +546,57 @@ func failingTestNames(out string) []string {
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
fields := strings.Fields(testCmd)
|
||||
fields := splitShellFields(testCmd)
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
|
||||
func splitShellFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inSingle, inDouble := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case inDouble:
|
||||
if c == '"' {
|
||||
inDouble = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
@@ -561,10 +642,15 @@ func gitOut(repo string, args ...string) string {
|
||||
func truncate(s string) string { return truncateFor(s, 12000) }
|
||||
|
||||
func truncateFor(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "\n…(truncated)…"
|
||||
// Back the cut up to a rune boundary so we don't split a multi-byte UTF-8 rune.
|
||||
cut := max
|
||||
for cut > 0 && !utf8.RuneStart(s[cut]) {
|
||||
cut--
|
||||
}
|
||||
return s[:cut] + "\n…(truncated)…"
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
|
||||
+52
-15
@@ -34,6 +34,7 @@ type runMetrics struct {
|
||||
Steps int `json:"steps"`
|
||||
Cost float64 `json:"cost"`
|
||||
Currency string `json:"currency"`
|
||||
Compactions int `json:"compactions"`
|
||||
}
|
||||
|
||||
type result struct {
|
||||
@@ -45,6 +46,17 @@ type result struct {
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", flag.CommandLine.Name())
|
||||
flag.PrintDefaults()
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "\nExamples:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Run the committed suite:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s\n\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " # Grade a PR's diff with a retry budget:\n")
|
||||
fmt.Fprintf(flag.CommandLine.Output(), " %[1]s -mode diff -base origin/main -repo . -attempts 3 -timeout 1800\n", strings.Replace(flag.CommandLine.Name(), "e2ebench", "go run ./cmd/e2ebench", 1))
|
||||
}
|
||||
|
||||
mode := flag.String("mode", "suite", "suite | diff (diff = generate tests for the PR diff and grade with the repo's tests)")
|
||||
suite := flag.String("suite", "benchmarks/e2e", "suite root (contains tasks/<id>/)")
|
||||
bin := flag.String("bin", "reasonix", "path to the reasonix binary")
|
||||
@@ -76,7 +88,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "no tasks found under", filepath.Join(*suite, "tasks"))
|
||||
dir := filepath.Join(*suite, "tasks")
|
||||
if _, statErr := os.Stat(dir); statErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "no tasks found under %s: %v\n", dir, statErr)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "no tasks found under %s (the directory exists but contains no task.toml files)\n", dir)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -102,8 +119,15 @@ func main() {
|
||||
fmt.Print(report)
|
||||
}
|
||||
if *outJSON != "" {
|
||||
b, _ := json.MarshalIndent(results, "", " ")
|
||||
_ = os.WriteFile(*outJSON, b, 0o644)
|
||||
b, err := json.MarshalIndent(results, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "marshal json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*outJSON, b, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write json:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +206,7 @@ func runTask(bin, model string, t task) result {
|
||||
cmd.Dir = work
|
||||
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
|
||||
runErr := cmd.Run()
|
||||
|
||||
if m, err := readMetrics(metricsPath); err == nil {
|
||||
@@ -215,7 +240,7 @@ func grade(work, taskDir string) bool {
|
||||
func render(results []result) string {
|
||||
var b strings.Builder
|
||||
passed, ran := 0, 0
|
||||
var pTok, cTok, hit, miss int
|
||||
var pTok, cTok, hit, miss, compacts int
|
||||
var cost float64
|
||||
currency := ""
|
||||
for _, r := range results {
|
||||
@@ -230,6 +255,7 @@ func render(results []result) string {
|
||||
cTok += r.CompletionTokens
|
||||
hit += r.CacheHitTokens
|
||||
miss += r.CacheMissTokens
|
||||
compacts += r.Compactions
|
||||
cost += r.Cost
|
||||
if r.Currency != "" {
|
||||
currency = r.Currency
|
||||
@@ -237,28 +263,28 @@ func render(results []result) string {
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "## 🤖 Reasonix e2e benchmark\n\n")
|
||||
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Cost:** %s%.4f\n\n",
|
||||
fmt.Fprintf(&b, "**Accuracy:** %d/%d (%s) · **Cache hit:** %s · **Tokens:** %s (prompt %s / completion %s) · **Compactions:** %d · **Cost:** %s%.4f\n\n",
|
||||
passed, ran, pct(passed, ran), pct(hit, hit+miss),
|
||||
comma(pTok+cTok), comma(pTok), comma(cTok), currencySym(currency), cost)
|
||||
comma(pTok+cTok), comma(pTok), comma(cTok), compacts, currencySym(currency), cost)
|
||||
|
||||
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Cost |\n")
|
||||
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|-----:|\n")
|
||||
fmt.Fprintf(&b, "| Task | Result | Steps | Prompt | Completion | Cache hit | Compact | Cost |\n")
|
||||
fmt.Fprintf(&b, "|------|--------|------:|-------:|-----------:|----------:|--------:|-----:|\n")
|
||||
for _, r := range results {
|
||||
switch {
|
||||
case r.Skipped:
|
||||
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — |\n", r.ID)
|
||||
fmt.Fprintf(&b, "| `%s` | ⏭️ skipped | — | — | — | — | — | — |\n", r.ID)
|
||||
default:
|
||||
res := "❌ fail"
|
||||
if r.Passed {
|
||||
res = "✅ pass"
|
||||
}
|
||||
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %s%.4f |\n",
|
||||
fmt.Fprintf(&b, "| `%s` | %s | %d | %s | %s | %s | %d | %s%.4f |\n",
|
||||
r.ID, res, r.Steps, comma(r.PromptTokens), comma(r.CompletionTokens),
|
||||
pct(r.CacheHitTokens, r.CacheHitTokens+r.CacheMissTokens),
|
||||
currencySym(r.Currency), r.Cost)
|
||||
r.Compactions, currencySym(r.Currency), r.Cost)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n<sub>Real provider run on the PR head. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
|
||||
fmt.Fprintf(&b, "\n<sub>Real provider run. Cache-hit %% is cached prompt tokens / total prompt tokens.</sub>\n")
|
||||
|
||||
notes := false
|
||||
for _, r := range results {
|
||||
@@ -329,6 +355,10 @@ func copyDir(src, dst string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Skip symlinks so a seed link can't leak a file from outside the seed tree.
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(src, p)
|
||||
target := filepath.Join(dst, rel)
|
||||
if info.IsDir() {
|
||||
@@ -347,11 +377,18 @@ func copyFile(src, dst string) error {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
info, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, in)
|
||||
return err
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
// Mirror the source mode so a seed's read-only / exec bit survives the copy.
|
||||
return os.Chmod(dst, info.Mode().Perm())
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMutants = 15
|
||||
@@ -68,8 +69,17 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
}
|
||||
cmd := exec.Command("go", "test", "-run", runRe, pkg)
|
||||
cmd.Dir = repo
|
||||
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
|
||||
// Restore source even on panic; a file left mutated would corrupt the next mutant.
|
||||
restored := false
|
||||
defer func() {
|
||||
if !restored {
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
}
|
||||
}()
|
||||
caught := cmd.Run() != nil
|
||||
_ = os.WriteFile(abs, srcB, 0o644) // restore before the next mutant
|
||||
_ = os.WriteFile(abs, srcB, 0o644)
|
||||
restored = true
|
||||
if caught {
|
||||
res.caught++
|
||||
} else {
|
||||
@@ -81,12 +91,16 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
|
||||
}
|
||||
|
||||
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
|
||||
// main/init are skipped (no meaningful return to mutate).
|
||||
// main/init and nil-named decls are skipped (no meaningful return to mutate).
|
||||
func changedFuncs(fset *token.FileSet, f *ast.File, lines map[int]bool) []*ast.FuncDecl {
|
||||
var out []*ast.FuncDecl
|
||||
for _, decl := range f.Decls {
|
||||
fd, ok := decl.(*ast.FuncDecl)
|
||||
if !ok || fd.Body == nil || fd.Name.Name == "main" || fd.Name.Name == "init" {
|
||||
if !ok || fd.Body == nil || fd.Name == nil {
|
||||
continue
|
||||
}
|
||||
name := fd.Name.Name
|
||||
if name == "main" || name == "init" {
|
||||
continue
|
||||
}
|
||||
start := fset.Position(fd.Pos()).Line
|
||||
|
||||
+5
-2
@@ -90,6 +90,8 @@ tags are the CLI release). Pushing one triggers `.github/workflows/release-deskt
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
```sh
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
@@ -111,8 +113,9 @@ has a manual check. Self-update behavior by platform:
|
||||
There are no Apple/Windows code-signing certificates yet, so a downloaded build
|
||||
trips the OS gatekeepers on first run:
|
||||
|
||||
- **macOS** — Gatekeeper may report the app "is damaged" or is from an
|
||||
unidentified developer. Clear the quarantine attribute, then open it:
|
||||
- **macOS** — open `Reasonix-darwin-universal.dmg` and drag Reasonix into
|
||||
Applications. Gatekeeper may then report the app "is damaged" or is from an
|
||||
unidentified developer; clear the quarantine attribute and open it:
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine /Applications/Reasonix.app
|
||||
```
|
||||
|
||||
+1181
-522
File diff suppressed because it is too large.
Load diff
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -46,27 +47,63 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
t.Fatalf("session file %q never contained %q", path, want)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
tab.saveMu.Unlock()
|
||||
if idle {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("autosave loop did not become idle")
|
||||
}
|
||||
|
||||
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
||||
t.Helper()
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "test_tab",
|
||||
Ctrl: ctrl,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: "",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
|
||||
a := &App{
|
||||
tabs: map[string]*WorkspaceTab{"test_tab": tab},
|
||||
activeTabID: "test_tab",
|
||||
}
|
||||
tab.sink.app = a
|
||||
return a, tab
|
||||
}
|
||||
|
||||
// TestTurnDonePersistsSession proves a completed turn is written to disk without
|
||||
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
|
||||
// nil sink ctx (no webview) must not disable persistence.
|
||||
func TestTurnDonePersistsSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a := &App{ctrl: controllerWithContent(t, path)}
|
||||
a.sink = &eventSink{app: a}
|
||||
_ = path
|
||||
a, tab := appWithTab(t, path)
|
||||
|
||||
a.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
waitForFile(t, path, "remember this turn")
|
||||
_ = a
|
||||
}
|
||||
|
||||
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
||||
// per-token event storm doesn't thrash the disk.
|
||||
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a := &App{ctrl: controllerWithContent(t, path)}
|
||||
a.sink = &eventSink{app: a}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
a.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
|
||||
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
@@ -78,12 +115,19 @@ func TestNonTurnDoneDoesNotPersist(t *testing.T) {
|
||||
// single-flight loop neither panics nor drops the final write.
|
||||
func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a := &App{ctrl: controllerWithContent(t, path)}
|
||||
a.sink = &eventSink{app: a}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
go a.scheduleSnapshot()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
waitForFile(t, path, "acknowledged")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
+488
-116
@@ -6,14 +6,53 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// setTestCtrl creates a minimal workspace tab (if needed) and sets its
|
||||
// controller, so tests don't depend on the old App.ctrl field.
|
||||
func (a *App) setTestCtrl(ctrl *control.Controller, model string) {
|
||||
if len(a.tabs) == 0 {
|
||||
tab := &WorkspaceTab{
|
||||
ID: "test",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabs = map[string]*WorkspaceTab{"test": tab}
|
||||
a.activeTabID = "test"
|
||||
}
|
||||
tab := a.tabs["test"]
|
||||
tab.Ctrl = ctrl
|
||||
tab.model = model
|
||||
}
|
||||
|
||||
func isolateDesktopUserDirs(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
xdg := filepath.Join(home, ".config")
|
||||
appData := filepath.Join(home, "AppData")
|
||||
for _, dir := range []string{xdg, appData} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||
t.Setenv("AppData", appData)
|
||||
return home
|
||||
}
|
||||
|
||||
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
|
||||
app := NewApp()
|
||||
cmds := app.Commands()
|
||||
@@ -26,8 +65,7 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEffortDefaultsBeforeStartup(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
got := NewApp().Effort()
|
||||
if !got.Supported || got.Current != "auto" || got.Default != "high" || !hasLevel(got.Levels, "auto") {
|
||||
@@ -35,61 +73,44 @@ func TestEffortDefaultsBeforeStartup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsFallbackKeepsNestedDefaultsOnLoadFailure(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
|
||||
func TestBeforeCloseAllowsSystemQuitWhenBackgroundCloseEnabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
consumeSystemQuitRequested()
|
||||
t.Cleanup(func() { consumeSystemQuitRequested() })
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := NewApp().Settings()
|
||||
if got.Providers == nil || got.ProviderKinds == nil {
|
||||
t.Fatalf("Settings fallback providers/kinds should be non-nil: %+v", got)
|
||||
markSystemQuitRequested()
|
||||
if prevent := NewApp().beforeClose(context.Background()); prevent {
|
||||
t.Fatal("system quit should bypass background close-to-tray behavior")
|
||||
}
|
||||
if got.Permissions.Mode != "ask" || got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil {
|
||||
t.Fatalf("Settings fallback permissions should be safe defaults: %+v", got.Permissions)
|
||||
}
|
||||
if got.Sandbox.Bash != "enforce" || got.Sandbox.AllowWrite == nil {
|
||||
t.Fatalf("Settings fallback sandbox should be safe defaults: %+v", got.Sandbox)
|
||||
if consumeSystemQuitRequested() {
|
||||
t.Fatal("system quit marker should be consumed by beforeClose")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildKeepsOldControllerOnBuildFailure(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
func TestEmitReadyInvokesReadyHook(t *testing.T) {
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.model = "deepseek-flash/deepseek-v4-flash"
|
||||
old := control.New(control.Options{Label: "old-controller"})
|
||||
app.ctrl = old
|
||||
defer func() {
|
||||
if app.ctrl != nil {
|
||||
app.ctrl.Close()
|
||||
}
|
||||
}()
|
||||
var calls int32
|
||||
app.readyHook = func() {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte("default_model = [\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := app.rebuild(); err == nil {
|
||||
t.Fatal("rebuild should fail for malformed config")
|
||||
}
|
||||
if app.ctrl != old {
|
||||
t.Fatalf("rebuild failure replaced controller: got %p want old %p", app.ctrl, old)
|
||||
}
|
||||
if app.startupErr == "" {
|
||||
t.Fatal("rebuild failure should surface startupErr")
|
||||
app.emitReady(nil)
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("ready hook calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEffortPersistsAndAutoClears(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
if err := app.SetEffort("max"); err != nil {
|
||||
@@ -113,28 +134,133 @@ func TestSetEffortPersistsAndAutoClears(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsUsesUserDesktopPreferencesNotProjectConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
language = "zh"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("set desktop language: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
|
||||
t.Fatalf("set desktop close behavior: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got := NewApp().Settings()
|
||||
if got.DesktopLanguage != "en" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" {
|
||||
t.Fatalf("desktop settings = lang:%q theme:%q style:%q close:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "legacy-provider/legacy-model"
|
||||
|
||||
[desktop]
|
||||
language = "zh"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got := app.Settings()
|
||||
if got.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" {
|
||||
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
|
||||
}
|
||||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" {
|
||||
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q theme:%q style:%q close:%q", userCfg.DesktopLanguage(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateDesktopPreferencesDoesNotOverwriteExistingConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("set desktop language: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
if err := NewApp().MigrateDesktopPreferences("zh", "light", "glacier"); err != nil {
|
||||
t.Fatalf("migrate desktop preferences: %v", err)
|
||||
}
|
||||
|
||||
got := config.LoadForEdit(config.UserConfigPath())
|
||||
if got.DesktopLanguage() != "en" || got.DesktopTheme() != "dark" || got.DesktopThemeStyle() != "graphite" {
|
||||
t.Fatalf("desktop prefs after migration = lang:%q theme:%q style:%q, want existing config preserved", got.DesktopLanguage(), got.DesktopTheme(), got.DesktopThemeStyle())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEffortRebuildsController(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.model = "deepseek-flash/deepseek-v4-flash"
|
||||
app.readyHook = func() {}
|
||||
old := control.New(control.Options{Label: "old-controller"})
|
||||
app.ctrl = old
|
||||
app.setTestCtrl(old, "deepseek-flash/deepseek-v4-flash")
|
||||
defer func() {
|
||||
if app.ctrl != nil {
|
||||
app.ctrl.Close()
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.SetEffort("max"); err != nil {
|
||||
t.Fatalf("SetEffort(max): %v", err)
|
||||
}
|
||||
if app.ctrl == nil {
|
||||
if c := app.activeCtrl(); c == nil {
|
||||
t.Fatal("SetEffort should leave a rebuilt controller")
|
||||
}
|
||||
if app.ctrl == old {
|
||||
if c := app.activeCtrl(); c == old {
|
||||
t.Fatal("SetEffort should rebuild the active controller so the provider sees the new effort")
|
||||
}
|
||||
if got := app.Effort().Current; got != "max" {
|
||||
@@ -143,13 +269,12 @@ func TestSetEffortRebuildsController(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Runner: runner})
|
||||
app.ctrl.Submit("work")
|
||||
app.setTestCtrl(control.New(control.Options{Runner: runner}), "")
|
||||
app.activeCtrl().Submit("work")
|
||||
<-runner.started
|
||||
|
||||
err := app.SetEffort("max")
|
||||
@@ -158,7 +283,7 @@ func TestSetEffortRejectsRunningTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
close(runner.release)
|
||||
waitNotRunning(t, app.ctrl)
|
||||
waitNotRunning(t, app.activeCtrl())
|
||||
}
|
||||
|
||||
func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
|
||||
@@ -191,9 +316,51 @@ func TestSearchFileRefsFindsNestedBasename(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRefsUseActiveTabWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(launchRoot, "launch-only.txt"), []byte("wrong"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectRoot, "frontend", "wailsjs", "runtime"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectFile := filepath.Join(projectRoot, "frontend", "wailsjs", "runtime", "runtime.js")
|
||||
if err := os.WriteFile(projectFile, []byte("right workspace"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "project", Scope: "project", WorkspaceRoot: projectRoot}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
listed := app.ListDir("")
|
||||
if !hasDirEntry(listed, "frontend") {
|
||||
t.Fatalf("ListDir should list active project root, got %+v", listed)
|
||||
}
|
||||
if hasDirEntry(listed, "launch-only.txt") {
|
||||
t.Fatalf("ListDir leaked launch cwd entries, got %+v", listed)
|
||||
}
|
||||
|
||||
found := app.SearchFileRefs("runtime.js")
|
||||
if !hasDirEntry(found, "frontend/wailsjs/runtime/runtime.js") {
|
||||
t.Fatalf("SearchFileRefs should search active project root, got %+v", found)
|
||||
}
|
||||
preview := app.ReadFile("frontend/wailsjs/runtime/runtime.js")
|
||||
if preview.Err != "" || preview.Body != "right workspace" {
|
||||
t.Fatalf("ReadFile active project preview = %+v, want project file", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -205,8 +372,12 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test"}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.DeleteSession(filepath.Base(path)); err != errActiveSession {
|
||||
t.Fatalf("DeleteSession(active basename) error = %v, want errActiveSession", err)
|
||||
@@ -216,9 +387,175 @@ func TestDeleteSessionRejectsActiveRelativePath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionRejectsInactiveOpenTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
activePath := filepath.Join(dir, "active.jsonl")
|
||||
inactivePath := filepath.Join(dir, "inactive.jsonl")
|
||||
otherPath := filepath.Join(dir, "other.jsonl")
|
||||
for _, path := range []string{activePath, inactivePath, otherPath} {
|
||||
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
activeCtrl := control.New(control.Options{SessionDir: dir, SessionPath: activePath, Label: "active"})
|
||||
inactiveCtrl := control.New(control.Options{SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
|
||||
defer activeCtrl.Close()
|
||||
defer inactiveCtrl.Close()
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
|
||||
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
|
||||
},
|
||||
tabOrder: []string{"active", "inactive"},
|
||||
activeTabID: "active",
|
||||
}
|
||||
|
||||
if err := app.DeleteSession(filepath.Base(inactivePath)); err != errActiveSession {
|
||||
t.Fatalf("DeleteSession(inactive open basename) error = %v, want errActiveSession", err)
|
||||
}
|
||||
if _, err := os.Stat(inactivePath); err != nil {
|
||||
t.Fatalf("inactive open session should remain: %v", err)
|
||||
}
|
||||
|
||||
sessions := app.ListSessions()
|
||||
current := map[string]bool{}
|
||||
open := map[string]bool{}
|
||||
for _, s := range sessions {
|
||||
current[filepath.Base(s.Path)] = s.Current
|
||||
open[filepath.Base(s.Path)] = s.Open
|
||||
}
|
||||
if !current[filepath.Base(activePath)] {
|
||||
t.Fatalf("ListSessions should mark active session current, got %#v", current)
|
||||
}
|
||||
if current[filepath.Base(inactivePath)] {
|
||||
t.Fatalf("ListSessions should not mark inactive open session current, got %#v", current)
|
||||
}
|
||||
if current[filepath.Base(otherPath)] {
|
||||
t.Fatalf("ListSessions marked unopened session current, got %#v", current)
|
||||
}
|
||||
if !open[filepath.Base(activePath)] || !open[filepath.Base(inactivePath)] {
|
||||
t.Fatalf("ListSessions should mark active and inactive open sessions open, got %#v", open)
|
||||
}
|
||||
if open[filepath.Base(otherPath)] {
|
||||
t.Fatalf("ListSessions marked unopened session open, got %#v", open)
|
||||
}
|
||||
}
|
||||
|
||||
type appendingDesktopRunner struct {
|
||||
session *agent.Session
|
||||
started chan string
|
||||
}
|
||||
|
||||
func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
|
||||
r.started <- input
|
||||
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
|
||||
r.session.Add(provider.Message{Role: provider.RoleAssistant, Content: "ok"})
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestForkCreatesActiveTabWithoutSwitchingSourceController(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
workspace := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte("[codegraph]\nenabled = false\n"), 0o644); err != nil {
|
||||
t.Fatalf("write workspace config: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
path := agent.NewSessionPath(dir, "test")
|
||||
sess := agent.NewSession("sys")
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
runner := &appendingDesktopRunner{session: sess, started: make(chan string, 2)}
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: runner,
|
||||
Executor: exec,
|
||||
Sink: event.Discard,
|
||||
SessionDir: dir,
|
||||
SessionPath: path,
|
||||
Label: "test",
|
||||
WorkspaceRoot: workspace,
|
||||
})
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "deepseek/test")
|
||||
app.tabs["test"].Scope = "project"
|
||||
app.tabs["test"].WorkspaceRoot = workspace
|
||||
app.tabs["test"].TopicID = "topic_source"
|
||||
app.tabs["test"].TopicTitle = "Source topic"
|
||||
defer ctrl.Close()
|
||||
|
||||
ctrl.Submit("first")
|
||||
<-runner.started
|
||||
waitNotRunning(t, ctrl)
|
||||
ctrl.Submit("second")
|
||||
<-runner.started
|
||||
waitNotRunning(t, ctrl)
|
||||
if got := len(ctrl.History()); got != 5 {
|
||||
t.Fatalf("source history len before fork = %d, want 5", got)
|
||||
}
|
||||
|
||||
meta, err := app.Fork(1)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if !meta.Active || meta.ID == "" || meta.ID == "test" {
|
||||
t.Fatalf("fork meta = %+v, want a new active tab", meta)
|
||||
}
|
||||
if got := app.activeTabID; got != meta.ID {
|
||||
t.Fatalf("active tab = %q, want fork tab %q", got, meta.ID)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got != path {
|
||||
t.Fatalf("source controller session path = %q, want %q", got, path)
|
||||
}
|
||||
if got := len(ctrl.History()); got != 5 {
|
||||
t.Fatalf("source history len after fork = %d, want 5", got)
|
||||
}
|
||||
if got, want := meta.TopicTitle, "Source topic · 分叉"; got != want {
|
||||
t.Fatalf("fork topic title = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
var forkPath string
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read session dir: %v", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
candidate := filepath.Join(dir, entry.Name())
|
||||
if candidate == path {
|
||||
continue
|
||||
}
|
||||
m, ok, err := agent.LoadBranchMeta(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("load fork meta: %v", err)
|
||||
}
|
||||
if ok && m.TopicID == meta.TopicID {
|
||||
forkPath = candidate
|
||||
if m.ParentID != agent.BranchID(path) || m.ForkTurn != 1 || m.ForkMessageIndex != 3 {
|
||||
t.Fatalf("fork branch meta = %+v, want parent %q turn 1 index 3", m, agent.BranchID(path))
|
||||
}
|
||||
if m.Scope != "project" || m.WorkspaceRoot != workspace || m.TopicTitle != "Source topic · 分叉" {
|
||||
t.Fatalf("fork topic meta = %+v", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if forkPath == "" {
|
||||
t.Fatalf("fork session with topic %q not found in %s", meta.TopicID, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsLazyMCPAsDeferredNotDisabled(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -234,8 +571,12 @@ args = ["-y", "@playwright/mcp"]
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -250,16 +591,13 @@ args = ["-y", "@playwright/mcp"]
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
t.Setenv("AppData", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -283,8 +621,7 @@ func TestCapabilitiesShowsDefaultCodegraphDisabled(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCapabilitiesMarksDeferredRemoteMCPAuthPossible(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -301,8 +638,8 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -317,8 +654,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestCapabilitiesDoesNotMarkRemoteMCPWithAuthHeaderPossible(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -336,8 +672,8 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -352,8 +688,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestCapabilitiesMarksAuthFailureRequired(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -372,8 +707,8 @@ tier = "lazy"
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: host})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: host}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -388,8 +723,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestClearMCPServerAuthenticationClearsConfigAndFailure(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -410,8 +744,8 @@ tier = "lazy"
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: host})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: host}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.ClearMCPServerAuthentication("figma"); err != nil {
|
||||
t.Fatalf("ClearMCPServerAuthentication: %v", err)
|
||||
@@ -452,8 +786,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestUpdateMCPServerKeepsLazyMCPDeferred(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -470,8 +803,12 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.UpdateMCPServer("playwright", MCPServerInput{
|
||||
Name: "playwright",
|
||||
@@ -492,6 +829,18 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
|
||||
if got := cfg.Plugins[0].Env["TOKEN"]; got != "${PLAYWRIGHT_TOKEN}" {
|
||||
t.Fatalf("env TOKEN = %q, want preserved env", got)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userPlugin, ok := findPluginEntry(userCfg.Plugins, "playwright")
|
||||
if !ok {
|
||||
t.Fatalf("playwright should be migrated to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
if userPlugin.Command != "node" || userPlugin.Env["TOKEN"] != "${PLAYWRIGHT_TOKEN}" {
|
||||
t.Fatalf("user plugin after migration = %+v", userPlugin)
|
||||
}
|
||||
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
|
||||
if _, ok := findPluginEntry(projectCfg.Plugins, "playwright"); ok {
|
||||
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
@@ -508,8 +857,7 @@ env = { TOKEN = "${PLAYWRIGHT_TOKEN}" }
|
||||
}
|
||||
|
||||
func TestUpdateMCPServerRecordsReconnectFailure(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -525,8 +873,8 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.UpdateMCPServer("broken", MCPServerInput{
|
||||
Name: "broken",
|
||||
@@ -546,8 +894,8 @@ tier = "lazy"
|
||||
if got := cfg.Plugins[0].Tier; got != "background" {
|
||||
t.Fatalf("updated tier = %q, want background", got)
|
||||
}
|
||||
if !mcpFailed(app.ctrl, "broken") {
|
||||
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
|
||||
if !mcpFailed(app.activeCtrl(), "broken") {
|
||||
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -565,8 +913,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestSetMCPServerTierRecordsConnectFailure(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -582,8 +929,12 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.SetMCPServerTier("broken", "background"); err != nil {
|
||||
t.Fatalf("SetMCPServerTier should persist tier even when immediate connect fails: %v", err)
|
||||
@@ -595,8 +946,20 @@ tier = "lazy"
|
||||
if got := cfg.Plugins[0].Tier; got != "background" {
|
||||
t.Fatalf("saved tier = %q, want background", got)
|
||||
}
|
||||
if !mcpFailed(app.ctrl, "broken") {
|
||||
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.ctrl.Host().Failures())
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userPlugin, ok := findPluginEntry(userCfg.Plugins, "broken")
|
||||
if !ok {
|
||||
t.Fatalf("broken should be migrated to user config: %+v", userCfg.Plugins)
|
||||
}
|
||||
if userPlugin.Tier != "background" {
|
||||
t.Fatalf("user plugin tier = %q, want background", userPlugin.Tier)
|
||||
}
|
||||
projectCfg := config.LoadForEdit(filepath.Join(dir, "reasonix.toml"))
|
||||
if _, ok := findPluginEntry(projectCfg.Plugins, "broken"); ok {
|
||||
t.Fatalf("project plugin should be removed after desktop migration: %+v", projectCfg.Plugins)
|
||||
}
|
||||
if !mcpFailed(app.activeCtrl(), "broken") {
|
||||
t.Fatalf("Host.Failures() = %+v, want broken failure recorded", app.activeCtrl().Host().Failures())
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -631,8 +994,8 @@ auto_install = true
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerTier("codegraph", "background"); err != nil {
|
||||
t.Fatalf("SetMCPServerTier(codegraph): %v", err)
|
||||
@@ -647,8 +1010,15 @@ auto_install = true
|
||||
if got := cfg.Codegraph.Tier; got != "background" {
|
||||
t.Fatalf("codegraph tier = %q, want background", got)
|
||||
}
|
||||
if !mcpFailed(app.ctrl, "codegraph") {
|
||||
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.ctrl.Host().Failures())
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !userCfg.Codegraph.Enabled {
|
||||
t.Fatal("user codegraph enabled = false, want true after selecting a startup tier")
|
||||
}
|
||||
if got := userCfg.Codegraph.Tier; got != "background" {
|
||||
t.Fatalf("user codegraph tier = %q, want background", got)
|
||||
}
|
||||
if !mcpFailed(app.activeCtrl(), "codegraph") {
|
||||
t.Fatalf("Host.Failures() = %+v, want codegraph failure recorded for missing runtime", app.activeCtrl().Host().Failures())
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
@@ -666,8 +1036,7 @@ auto_install = true
|
||||
}
|
||||
|
||||
func TestSetMCPServerEnabledPersistsCodegraphOff(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -679,8 +1048,8 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
|
||||
if err := app.SetMCPServerEnabled("codegraph", false); err != nil {
|
||||
t.Fatalf("SetMCPServerEnabled(codegraph,false): %v", err)
|
||||
@@ -692,6 +1061,10 @@ tier = "lazy"
|
||||
if cfg.Codegraph.Enabled {
|
||||
t.Fatal("codegraph enabled = true, want false after disabling")
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.Codegraph.Enabled {
|
||||
t.Fatal("user codegraph enabled = true, want false after disabling")
|
||||
}
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "codegraph" {
|
||||
@@ -705,8 +1078,7 @@ tier = "lazy"
|
||||
}
|
||||
|
||||
func TestCapabilitiesKeepsFailedMCPConfiguredTierAfterRestart(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
@@ -722,9 +1094,9 @@ tier = "eager"
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctrl = control.New(control.Options{Host: plugin.NewHost()})
|
||||
defer app.ctrl.Close()
|
||||
recordMCPFailure(app.ctrl, config.PluginEntry{
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer app.activeCtrl().Close()
|
||||
recordMCPFailure(app.activeCtrl(), config.PluginEntry{
|
||||
Name: "broken",
|
||||
Command: "reasonix-missing-mcp-binary",
|
||||
Tier: "eager",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
cases := []struct {
|
||||
name string
|
||||
got any
|
||||
}{
|
||||
{"Checkpoints", app.Checkpoints()},
|
||||
{"ListSessions", app.ListSessions()},
|
||||
{"ListTrashedSessions", app.ListTrashedSessions()},
|
||||
{"ListWorkspaces", app.ListWorkspaces()},
|
||||
{"History", app.History()},
|
||||
{"Jobs", app.Jobs()},
|
||||
{"Commands", app.Commands()},
|
||||
{"Models", app.Models()},
|
||||
{"ListDir", app.ListDir("__missing__")},
|
||||
{"ListTabs", app.ListTabs()},
|
||||
{"ListProjectTree", app.ListProjectTree()},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertNonNilSliceJSON(t, tc.name, tc.got)
|
||||
}
|
||||
|
||||
if got := app.SlashArgs("/skill "); got.Items == nil {
|
||||
t.Fatal("SlashArgs().Items is nil; frontend expects []")
|
||||
}
|
||||
if got := app.WorkspaceChanges(); got.Files == nil {
|
||||
t.Fatal("WorkspaceChanges().Files is nil; frontend expects []")
|
||||
}
|
||||
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
|
||||
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
|
||||
t.Helper()
|
||||
v := reflect.ValueOf(got)
|
||||
if v.Kind() != reflect.Slice {
|
||||
t.Fatalf("%s returned %T, want slice", name, got)
|
||||
}
|
||||
if v.IsNil() {
|
||||
t.Fatalf("%s returned nil slice; frontend expects []", name)
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("%s JSON marshal: %v", name, err)
|
||||
}
|
||||
if string(raw) == "null" {
|
||||
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Unicode true
|
||||
## Reasonix per-user NSIS installer.
|
||||
##
|
||||
## This file is COMMITTED and customized (Wails leaves an existing project.nsi
|
||||
## untouched and only regenerates wails_tools.nsh). The two customizations vs.
|
||||
## untouched and only regenerates wails_tools.nsh). The customizations vs.
|
||||
## Wails' default template:
|
||||
##
|
||||
## 1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
|
||||
@@ -13,6 +13,12 @@ Unicode true
|
||||
## 2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
|
||||
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
|
||||
## cannot write - so we inline HKCU versions below instead.
|
||||
## 3. InstallDir is remembered across updates via InstallDirRegKey +
|
||||
## InstallLocation (HKCU\...\Uninstall\InstallLocation). Without this, every
|
||||
## release forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if
|
||||
## they had moved the install to a different drive (e.g. D:\Tools\Reasonix);
|
||||
## the silent auto-updater would re-run with /S into the wrong dir, leaving
|
||||
## the old install orphaned.
|
||||
##
|
||||
## Everything else mirrors Wails' generated default. Defines below override the
|
||||
## ProjectInfo values that wails_tools.nsh would otherwise populate.
|
||||
@@ -66,7 +72,9 @@ ManifestDPIAware true
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
InstallDir "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}" # Per-user install location (no admin rights required).
|
||||
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
####
|
||||
@@ -82,6 +90,13 @@ ShowInstDetails show # This will always show the installation details.
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
# Persist the resolved install path so a subsequent update picks it up
|
||||
# via InstallDirRegKey above. Without this, every release would force the
|
||||
# user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
|
||||
# the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
|
||||
# updater re-runs this installer with /S and trusts the persisted path,
|
||||
# so it has to be present before the silent re-install.
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
@@ -95,6 +110,13 @@ ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
|
||||
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value
|
||||
; is missing (first install, or the user wiped the uninstaller registry).
|
||||
; Fall back to the per-user default so the directory page lands on a
|
||||
; usable path instead of crashing the install with "InstallDir empty".
|
||||
StrCmp $INSTDIR "" 0 +2
|
||||
StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
|
||||
@@ -85,3 +85,81 @@ func upsertEnvFile(path, key, value string) error {
|
||||
}
|
||||
return os.Setenv(key, value)
|
||||
}
|
||||
|
||||
// envFileKeys returns the set of KEY names assigned in a KEY=value file, empty
|
||||
// when the file is absent.
|
||||
func envFileKeys(path string) map[string]bool {
|
||||
keys := map[string]bool{}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return keys
|
||||
}
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if k, _, ok := strings.Cut(line, "="); ok {
|
||||
keys[strings.TrimSpace(k)] = true
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// promoteProviderKeysToCredentials copies any configured provider api_key_env that
|
||||
// currently resolves (from a project .env, ~/.env, or the OS env) into the global
|
||||
// credentials file when it isn't there yet, so a key set for one workspace follows
|
||||
// the user across every project. Promoted keys are then stripped from ~/.env so the
|
||||
// credentials file is the single source of truth; a project's own .env is
|
||||
// user-owned and left untouched.
|
||||
func promoteProviderKeysToCredentials(cfg *config.Config) {
|
||||
credPath := credentialsPath()
|
||||
have := envFileKeys(credPath)
|
||||
for _, p := range cfg.Providers {
|
||||
env := strings.TrimSpace(p.APIKeyEnv)
|
||||
if env == "" || have[env] {
|
||||
continue
|
||||
}
|
||||
val := os.Getenv(env)
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
if err := upsertEnvFile(credPath, env, val); err != nil {
|
||||
continue
|
||||
}
|
||||
have[env] = true
|
||||
removeHomeEnvKey(env)
|
||||
}
|
||||
}
|
||||
|
||||
// removeHomeEnvKey deletes a single KEY=value assignment from ~/.env (the legacy
|
||||
// fallback the old migration wrote to), leaving every other line intact. No-op when
|
||||
// ~/.env is absent or the credentials store resolves to ~/.env itself.
|
||||
func removeHomeEnvKey(key string) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, ".env")
|
||||
if sameConfigPath(path, credentialsPath()) {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var kept []string
|
||||
removed := false
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
check := strings.TrimPrefix(strings.TrimSpace(raw), "export ")
|
||||
if k, _, ok := strings.Cut(check, "="); ok && strings.TrimSpace(k) == key {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
if !removed {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(path, []byte(strings.Join(kept, "\n")), 0o600)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// TestUpsertEnvFile proves a new key is appended, an existing key is replaced in
|
||||
@@ -36,3 +38,64 @@ func TestUpsertEnvFile(t *testing.T) {
|
||||
t.Errorf("process env not updated: FOO=%q BAZ=%q", os.Getenv("FOO"), os.Getenv("BAZ"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv proves a provider key
|
||||
// that resolves only from a project .env / ~/.env is copied into the global
|
||||
// credentials store, removed from ~/.env, and that unrelated env vars are ignored.
|
||||
func TestPromoteProviderKeysLiftsProjectKeyAndStripsHomeEnv(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-test")
|
||||
t.Setenv("NPM_TOKEN", "secret")
|
||||
|
||||
promoteProviderKeysToCredentials(config.Default())
|
||||
|
||||
cred, err := os.ReadFile(config.UserCredentialsPath())
|
||||
if err != nil {
|
||||
t.Fatalf("credentials not written: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-test") {
|
||||
t.Errorf("provider key not promoted to credentials:\n%s", cred)
|
||||
}
|
||||
if strings.Contains(string(cred), "NPM_TOKEN") {
|
||||
t.Errorf("non-provider env var must not be promoted:\n%s", cred)
|
||||
}
|
||||
|
||||
rest, _ := os.ReadFile(homeEnv)
|
||||
if strings.Contains(string(rest), "DEEPSEEK_API_KEY") {
|
||||
t.Errorf("promoted key must be stripped from ~/.env:\n%s", rest)
|
||||
}
|
||||
if !strings.Contains(string(rest), "NPM_TOKEN=secret") {
|
||||
t.Errorf("unrelated ~/.env line must survive:\n%s", rest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteProviderKeysLeavesExistingCredentialsKey proves promotion never
|
||||
// overwrites a key already in the credentials store and leaves ~/.env untouched.
|
||||
func TestPromoteProviderKeysLeavesExistingCredentialsKey(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserCredentialsPath(), []byte("DEEPSEEK_API_KEY=sk-global\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DEEPSEEK_API_KEY", "sk-global")
|
||||
|
||||
promoteProviderKeysToCredentials(config.Default())
|
||||
|
||||
cred, _ := os.ReadFile(config.UserCredentialsPath())
|
||||
if !strings.Contains(string(cred), "DEEPSEEK_API_KEY=sk-global") {
|
||||
t.Errorf("existing credentials key was changed:\n%s", cred)
|
||||
}
|
||||
if data, err := os.Stat(homeEnv); err != nil || data.Size() == 0 {
|
||||
t.Errorf("~/.env should be untouched when key already global, err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -24,6 +25,7 @@
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"terser": "^5.48.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
|
||||
Generated
+60
-5
@@ -47,13 +47,16 @@ importers:
|
||||
version: 18.3.7(@types/[email protected])
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0([email protected])
|
||||
version: 4.7.0([email protected]([email protected]))
|
||||
terser:
|
||||
specifier: ^5.48.0
|
||||
version: 5.48.0
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.0.7
|
||||
version: 6.4.2
|
||||
version: 6.4.2([email protected])
|
||||
|
||||
packages:
|
||||
|
||||
@@ -306,6 +309,9 @@ packages:
|
||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -499,6 +505,11 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
|
||||
|
||||
@@ -512,6 +523,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
|
||||
@@ -533,6 +547,9 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||
engines: {node: '>= 12'}
|
||||
@@ -921,6 +938,13 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
|
||||
|
||||
@@ -933,6 +957,11 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1240,6 +1269,11 @@ snapshots:
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/[email protected]': {}
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
@@ -1384,7 +1418,7 @@ snapshots:
|
||||
|
||||
'@ungap/[email protected]': {}
|
||||
|
||||
'@vitejs/[email protected]([email protected])':
|
||||
'@vitejs/[email protected]([email protected]([email protected]))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/[email protected])
|
||||
@@ -1392,10 +1426,12 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 6.4.2
|
||||
vite: 6.4.2([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1408,6 +1444,8 @@ snapshots:
|
||||
node-releases: 2.0.46
|
||||
update-browserslist-db: 1.2.3([email protected])
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1422,6 +1460,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -2141,6 +2181,13 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
source-map: 0.6.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -2156,6 +2203,13 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.7
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.16.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fdir: 6.5.0([email protected])
|
||||
@@ -2231,7 +2285,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
[email protected]:
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
fdir: 6.5.0([email protected])
|
||||
@@ -2241,6 +2295,7 @@ snapshots:
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
terser: 5.48.0
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourcePath = path.join(root, "src", "lib", "todoVisibility.ts");
|
||||
const source = readFileSync(sourcePath, "utf8");
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ES2022,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText;
|
||||
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
||||
const { shouldShowTodoPanel } = await import(moduleUrl);
|
||||
|
||||
const completedTodos = [
|
||||
{ content: "Inspect the report", status: "completed" },
|
||||
{ content: "Ship the fix", status: "completed" },
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
shouldShowTodoPanel("todo-final", null, completedTodos),
|
||||
true,
|
||||
"the final all-completed todo_write must remain visible",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel("todo-active", null, [{ content: "Run tests", status: "in_progress" }]),
|
||||
true,
|
||||
"an active todo_write remains visible",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel("todo-final", "todo-final", completedTodos),
|
||||
false,
|
||||
"a user dismissal still hides that exact todo list",
|
||||
);
|
||||
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
|
||||
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
|
||||
|
||||
const iterations = 200_000;
|
||||
const started = performance.now();
|
||||
for (let i = 0; i < iterations; i += 1) {
|
||||
if (!shouldShowTodoPanel("todo-perf", null, completedTodos)) {
|
||||
throw new Error("unexpected hidden todo panel during performance loop");
|
||||
}
|
||||
}
|
||||
const elapsed = performance.now() - started;
|
||||
const perCallUs = (elapsed * 1000) / iterations;
|
||||
|
||||
assert.ok(elapsed < 500, `todo visibility check is too slow: ${elapsed.toFixed(2)} ms`);
|
||||
console.log(
|
||||
`todo visibility checks: ${iterations} calls in ${elapsed.toFixed(2)} ms (${perCallUs.toFixed(3)} us/call)`,
|
||||
);
|
||||
+945
-447
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1692.66 392.25">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0153e5;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M767.26,303.68c-12.37,1.71-24.96.46-38.13.63l-1.23-16.27c-12.87,14.9-29.84,21.97-49.07,19.87-24.58-2.69-44.41-19.38-53.2-42.6-7.32-19.32-6.99-40.86.81-60.12,9.5-23.46,30.72-40.21,55.73-42.02,18.41-1.33,34.33,4.84,45.96,18.54l1.13-15.37,37.63-.22.36,137.56ZM729.95,235.53c0-19.82-16.06-35.88-35.88-35.88s-35.88,16.06-35.88,35.88,16.06,35.88,35.88,35.88,35.88-16.06,35.88-35.88Z"/>
|
||||
<path class="cls-1" d="M573.23,258.02c11.08,4.99,20.6,11.87,31.28,18.31-14.54,22.26-39.3,32.78-65.14,31.91-42.29-1.42-73.12-35.5-70.7-77.31,2.1-36.29,28.66-64.56,65.17-67.92,26.31-2.42,52.01,8.19,66.02,30.99,9.52,15.5,11.55,33.39,9.76,50.92l-101,.13c1.4,14.77,13.51,25.01,25.92,27.74,14.72,3.24,29.02-.82,38.69-14.77ZM572.56,219.47c-4.12-16.52-17.68-24.4-32.5-24.13-13.74.25-27.5,9.07-30.5,24.11l63,.02Z"/>
|
||||
<path class="cls-1" d="M1058.84,235.62c0,40.74-33.02,73.76-73.76,73.76s-73.76-33.02-73.76-73.76,33.02-73.76,73.76-73.76,73.76,33.02,73.76,73.76ZM1021.17,235.57c0-19.97-16.19-36.16-36.16-36.16s-36.16,16.19-36.16,36.16,16.19,36.16,36.16,36.16,36.16-16.19,36.16-36.16Z"/>
|
||||
<path class="cls-1" d="M1144.36,198.98c-16.4-.48-30.2,10.42-30.32,26.41l-.59,78.88-38.91-.18.06-137.83,38.15.35c.41,5.66-1.03,9.97,1.45,15.75,19.54-26.51,63.34-25.07,82.95-1.16,9.2,11.23,13.46,25.62,13.45,40.38l-.06,82.62-38.94.14-.69-79.28c-.13-14.92-10.82-25.64-26.54-26.1Z"/>
|
||||
<path class="cls-1" d="M1430.09,303.82l-44.84.51-29.9-41.82-30.52,41.81-43.29-.52,50.71-70.03-48.12-67.06c16.29-1.81,30.31-.14,45.42-.02l26.06,38.81,27.14-39.29c14.85-.38,28.79-.61,44.5.69l-47.96,66.65,50.79,70.28Z"/>
|
||||
<path class="cls-1" d="M873.65,302.86c-30.01,11.52-78.69,6.96-94.37-27.63l29.88-14.4c4.49,1.96,5.5,6.8,9.44,9.49,16.43,11.17,43.99,9.75,44.36-4.61.12-4.72-3.46-8.89-8.58-10.29l-36.51-9.95c-11.77-3.21-22.59-9.81-28.13-21.2-10.25-21.06.12-45.53,21.63-54.96,29.01-12.72,77.53-8.27,89.7,25.13l-29.06,12.62c-10.47-12.8-27.66-16.84-42.64-10.42-3.51,1.5-5.54,6.03-5.28,8.81.35,3.7,3.39,7.71,7.59,8.86l40.46,11.09c9.77,2.68,19.02,7.82,24.29,16.77,13.52,22.96,2.31,51.05-22.78,60.69Z"/>
|
||||
<rect class="cls-1" x="1181.54" y="215.87" width="138.32" height="38.82" transform="translate(1014.48 1485.8) rotate(-89.96)"/>
|
||||
<circle class="cls-1" cx="1250.95" cy="124.55" r="23.88"/>
|
||||
<g>
|
||||
<path class="cls-1" d="M369.75,185.3c2.09,1.76,4.98,2.41,7.95,1.06,1.18-.54,1.96-1.91,1.47-3.25-3.92-10.71-14.97-18.54-19.38-12.48-.54.74-.52,1.7-.11,2.29.32.47.97.57,1.82.4,2.68-.56,6.02,1.31,5.7,4.3-.33,3.12.3,5.79,2.56,7.69Z"/>
|
||||
<path class="cls-1" d="M434.28,204.78c10.12-11.32,16.46-25.01,18.67-40.07,5.04-32.93-11.31-63.01-42.11-75.75-10.83-4.35-21.95-6.2-33.76-6.09l-114.5.03v219.42s56.29-.04,56.29-.04v-77.17c-1.86-.69-3.15-1.27-3.15-1.27-12.24-5.59-21.6-15.29-26.74-27.44-6.56-15.51-4.54-32.9,5.78-46.12,10.28-12.84,24.31-16.92,40.41-14.67,5.95.83,12.3-6.15,24.4-4.81.81.09,1.63.7,1.71,1.18.28,1.65-4.4,2.54-4.4,7.05,0,1.9.85,4.1,2.65,5.36,6.17,4.33,11.51,9.19,17.1,14.22,2.72,2.44,11.93,9.31,14.27,3.72,1.37-3.26,2.27-6.68,3.18-10.15.45-1.72-.5-2.74-1.89-3.56-9.5-5.63-13.08-17.35-9.37-27.74.36-1,1.29-1.49,2.08-1.52,3.19-.14,1.49,5.78,9.38,8.21,7.49,2.31,7.71,8.53,11.35,6.05,8.48-5.78,11.54-1.05,19.41-8.46.81-.76,2.2-.82,3.04-.23.52.36.97,1.29.93,2.38-.23,6.26-2.67,12.24-6.95,16.84-8.09,8.69-14.89,4.53-15.33,10.62-1.29,17.86-7.13,35.69-19.81,48.66-.41.42-.65,1.01-.58,1.41.07.42.57.83,1.12,1.01l10.68,3.63c1.51.51,2.6,1.87,2.41,3.31-.17,1.26-1.17,2.37-2.61,2.85-7.84,2.59-16.26,2.14-24.24-.27-10.56,8.89-23.54,13.48-37.68,12.93l20.53,28.34,33.49,45.73,66.85-.09-56.68-76.53c13.19-3.89,24.78-10.87,34.07-20.94Z"/>
|
||||
<circle class="cls-1" cx="362.14" cy="177.19" r="1.58"/>
|
||||
<path class="cls-1" d="M300.31,167.85c-3.53.43-5.65,2.96-5.31,6.74,2.39,26.35,26.7,47.92,53.94,42.72,5.8-1.11,11.08-3.41,15.36-7.74-9.27-7.98-13.79-16.12-21.41-24.13-10.91-11.47-26.43-19.54-42.58-17.59Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
import type { CSSProperties, ReactNode, RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
type PopoverPosition = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
const EDGE_GAP = 8;
|
||||
const DEFAULT_OFFSET = 8;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function samePosition(a: PopoverPosition | null, b: PopoverPosition): boolean {
|
||||
return !!a && Math.abs(a.left - b.left) < 0.5 && Math.abs(a.top - b.top) < 0.5;
|
||||
}
|
||||
|
||||
function calculatePosition(
|
||||
anchor: DOMRect,
|
||||
menu: DOMRect,
|
||||
align: "start" | "end",
|
||||
offset: number,
|
||||
placement: "auto" | "bottom",
|
||||
): PopoverPosition {
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const preferredTop = anchor.top - menu.height - offset;
|
||||
const fallbackTop = anchor.bottom + offset;
|
||||
const top = placement === "bottom"
|
||||
? Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP))
|
||||
: preferredTop >= EDGE_GAP
|
||||
? preferredTop
|
||||
: Math.min(fallbackTop, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP));
|
||||
const rawLeft = align === "end" ? anchor.right - menu.width : anchor.left;
|
||||
const left = clamp(rawLeft, EDGE_GAP, Math.max(EDGE_GAP, viewportWidth - menu.width - EDGE_GAP));
|
||||
return { left, top: clamp(top, EDGE_GAP, Math.max(EDGE_GAP, viewportHeight - menu.height - EDGE_GAP)) };
|
||||
}
|
||||
|
||||
export function AnchoredPopover({
|
||||
open,
|
||||
anchorRef,
|
||||
onClose,
|
||||
className,
|
||||
children,
|
||||
align = "start",
|
||||
offset = DEFAULT_OFFSET,
|
||||
placement = "auto",
|
||||
style,
|
||||
}: {
|
||||
open: boolean;
|
||||
anchorRef: RefObject<HTMLElement>;
|
||||
onClose: () => void;
|
||||
className: string;
|
||||
children: ReactNode;
|
||||
align?: "start" | "end";
|
||||
offset?: number;
|
||||
placement?: "auto" | "bottom";
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const [position, setPosition] = useState<PopoverPosition | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
const anchor = anchorRef.current?.getBoundingClientRect();
|
||||
const menu = document.querySelector<HTMLElement>("[data-anchored-popover='active']")?.getBoundingClientRect();
|
||||
if (!anchor || !menu) return;
|
||||
const next = calculatePosition(anchor, menu, align, offset, placement);
|
||||
setPosition((current) => (samePosition(current, next) ? current : next));
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
const closeOnViewportChange = () => onClose();
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
window.addEventListener("resize", closeOnViewportChange);
|
||||
window.addEventListener("scroll", closeOnViewportChange, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
window.removeEventListener("resize", closeOnViewportChange);
|
||||
window.removeEventListener("scroll", closeOnViewportChange, true);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="anchored-popover__backdrop" onMouseDown={onClose} />
|
||||
<div
|
||||
data-anchored-popover="active"
|
||||
className={`anchored-popover ${className}`}
|
||||
style={{
|
||||
...style,
|
||||
left: position?.left ?? -9999,
|
||||
top: position?.top ?? -9999,
|
||||
visibility: position ? "visible" : "hidden",
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export function ApprovalModal({
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const subject = approval.subject.trim();
|
||||
const subjectSummary = subject.split("\n").find((line) => line.trim())?.trim() ?? "";
|
||||
|
||||
const choosePlanAction = (key: string) => {
|
||||
if (key === "1") setRevisionOpen((open) => !open);
|
||||
@@ -123,10 +124,11 @@ export function ApprovalModal({
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
actionsWrap
|
||||
meta={
|
||||
<>
|
||||
<span className="tool__name">{approval.tool}</span>
|
||||
{subject && <span className="prompt-shelf__subject"> · {subject}</span>}
|
||||
{subjectSummary && <span className="prompt-shelf__subject"> · {subjectSummary}</span>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
|
||||
@@ -150,6 +150,7 @@ export function AskCard({
|
||||
barRef={shelfRef}
|
||||
titleId="ask-shelf-title"
|
||||
title={t("ask.title")}
|
||||
actionsWrap
|
||||
badges={
|
||||
<>
|
||||
{q.header && <PromptBadge>{q.header}</PromptBadge>}
|
||||
@@ -159,6 +160,11 @@ export function AskCard({
|
||||
meta={q.prompt}
|
||||
actions={
|
||||
<>
|
||||
{active > 0 && (
|
||||
<button className="prompt-action prompt-action--quiet" onClick={goBack}>
|
||||
<span className="prompt-action__label">{t("ask.back")}</span>
|
||||
</button>
|
||||
)}
|
||||
{q.options.map((o, index) => {
|
||||
const on = (sel[q.id] ?? []).includes(o.label);
|
||||
return (
|
||||
@@ -171,12 +177,20 @@ export function AskCard({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{q.multi && (
|
||||
<button className="prompt-action prompt-action--selected" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
|
||||
<span className="prompt-action__label">{isLast ? t("common.submit") : t("ask.next")}</span>
|
||||
</button>
|
||||
)}
|
||||
<PromptDetailToggle
|
||||
open={detailsOpen}
|
||||
label={t("ask.details")}
|
||||
openLabel={t("ask.hideDetails")}
|
||||
onClick={() => setDetailsOpen((open) => !open)}
|
||||
/>
|
||||
<button className="prompt-action prompt-action--quiet" onClick={onDismiss}>
|
||||
<span className="prompt-action__label">{t("ask.justChat")}</span>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
crumbs={
|
||||
@@ -190,25 +204,6 @@ export function AskCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
quickActions={
|
||||
!detailsOpen && (
|
||||
<div className="ask-shelf__quick-actions">
|
||||
{active > 0 && (
|
||||
<button className="btn" onClick={goBack}>
|
||||
{t("ask.back")}
|
||||
</button>
|
||||
)}
|
||||
<button className="btn" onClick={onDismiss}>
|
||||
{t("ask.justChat")}
|
||||
</button>
|
||||
{q.multi && (
|
||||
<button className="btn btn--primary" onClick={() => finishOrAdvance()} disabled={!currentAnswered}>
|
||||
{isLast ? t("common.submit") : t("ask.next")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailsOpen && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillView } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -21,7 +23,6 @@ export function CapabilitiesPanel({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<CapTab>("servers");
|
||||
const [skillQuery, setSkillQuery] = useState("");
|
||||
@@ -31,7 +32,7 @@ export function CapabilitiesPanel({
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] })));
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -84,7 +85,7 @@ export function CapabilitiesPanel({
|
||||
}, [filteredSkills, skillQuery, t, view]);
|
||||
|
||||
const serverGroups = useMemo(() => {
|
||||
const servers = view?.servers ?? [];
|
||||
const servers = sortServersForDisplay(view?.servers ?? []);
|
||||
return {
|
||||
failed: servers.filter((s) => s.status === "failed"),
|
||||
active: servers.filter((s) => s.status !== "failed"),
|
||||
@@ -181,10 +182,9 @@ export function CapabilitiesPanel({
|
||||
expanded={expandedErrors}
|
||||
onToggle={toggleError}
|
||||
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
|
||||
confirming={confirming}
|
||||
onConfirm={setConfirming}
|
||||
onCancelConfirm={() => setConfirming(null)}
|
||||
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
|
||||
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
@@ -196,17 +196,14 @@ export function CapabilitiesPanel({
|
||||
servers={serverGroups.active}
|
||||
expanded={expandedServers}
|
||||
expandedTools={expandedServerTools}
|
||||
confirming={confirming}
|
||||
editing={editing}
|
||||
onConfirm={setConfirming}
|
||||
onCancelConfirm={() => setConfirming(null)}
|
||||
onConfirm={(name) => void mutate(() => app.RemoveMCPServer(name))}
|
||||
onEdit={(name) => {
|
||||
setEditing(name);
|
||||
setConfirming(null);
|
||||
}}
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onRemove={(name) => mutate(() => app.RemoveMCPServer(name)).then(() => setConfirming(null))}
|
||||
onRetry={(name) => void mutate(() => app.RetryMCPServer(name))}
|
||||
onConfirmClearAuth={(name) => void mutate(() => app.ClearMCPServerAuthentication(name))}
|
||||
onToggle={(name, on) => void mutate(() => app.SetMCPServerEnabled(name, on))}
|
||||
onSetTier={(name, tier) => void mutate(() => app.SetMCPServerTier(name, tier))}
|
||||
onUpdate={(name, input) =>
|
||||
@@ -258,8 +255,10 @@ export function CapabilitiesPanel({
|
||||
<SkillRow
|
||||
key={sk.name}
|
||||
skill={sk}
|
||||
busy={busy}
|
||||
expanded={expandedSkills.has(sk.name)}
|
||||
onToggle={() => toggleSkill(sk.name)}
|
||||
onToggleEnabled={(enabled) => void mutate(() => app.SetSkillEnabled(sk.name, enabled))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -272,6 +271,39 @@ export function CapabilitiesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
|
||||
return {
|
||||
servers: sortServersForDisplay(
|
||||
asArray(view?.servers).map((server) => ({
|
||||
...server,
|
||||
args: asArray(server.args),
|
||||
envKeys: asArray(server.envKeys),
|
||||
toolList: asArray(server.toolList),
|
||||
})),
|
||||
),
|
||||
skills: asArray(view?.skills),
|
||||
skillRoots: asArray(view?.skillRoots).map((root) => ({
|
||||
...root,
|
||||
skillItems: asArray(root.skillItems),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function sortServersForDisplay(servers: ServerView[]): ServerView[] {
|
||||
return [...servers].sort((a, b) => {
|
||||
const priority = serverDisplayPriority(a) - serverDisplayPriority(b);
|
||||
if (priority !== 0) return priority;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function serverDisplayPriority(server: ServerView): number {
|
||||
if (server.status === "failed" || server.authStatus === "required") return 0;
|
||||
if (server.builtIn) return 1;
|
||||
if (server.status !== "disabled") return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
function skillListSummary(skills: SkillView[], filtered: SkillView[], searching: boolean, t: ReturnType<typeof useT>): string {
|
||||
if (searching) {
|
||||
return t("caps.skillsSummaryMatches", { matched: filtered.length, total: skills.length });
|
||||
@@ -324,7 +356,6 @@ function SkillSources({
|
||||
const t = useT();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [confirmingRoot, setConfirmingRoot] = useState<string | null>(null);
|
||||
const [expandedRootSkills, setExpandedRootSkills] = useState<Set<string>>(() => new Set());
|
||||
const [fullRootSkills, setFullRootSkills] = useState<Set<string>>(() => new Set());
|
||||
const primaryRoots = roots.filter(isPrimarySkillRoot);
|
||||
@@ -335,11 +366,6 @@ function SkillSources({
|
||||
const active = summaryRoots.filter((root) => root.skills > 0).length;
|
||||
const missing = summaryRoots.filter((root) => root.status === "missing").length;
|
||||
const empty = summaryRoots.filter((root) => root.status === "ok" && root.skills === 0).length;
|
||||
useEffect(() => {
|
||||
if (!confirmingRoot) return;
|
||||
const stillRemovable = roots.some((root) => root.dir === confirmingRoot && root.scope === "custom" && root.configured);
|
||||
if (!stillRemovable) setConfirmingRoot(null);
|
||||
}, [confirmingRoot, roots]);
|
||||
const toggleRootSkills = (key: string) => {
|
||||
setExpandedRootSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -421,43 +447,29 @@ function SkillSources({
|
||||
</div>
|
||||
{(canShowRootSkills || canRemoveRoot) && (
|
||||
<div className="cap-source-actions">
|
||||
{confirmingRoot === root.dir && canRemoveRoot ? (
|
||||
<>
|
||||
<>
|
||||
{canShowRootSkills && (
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
onRemove(root.dir);
|
||||
setConfirmingRoot(null);
|
||||
}}
|
||||
type="button"
|
||||
aria-expanded={rootSkillsExpanded}
|
||||
onClick={() => toggleRootSkills(key)}
|
||||
>
|
||||
{t("caps.skillRootConfirmRemove")}
|
||||
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div className="cap-confirm-hint">{t("caps.skillRootRemoveHint")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{canShowRootSkills && (
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy}
|
||||
type="button"
|
||||
aria-expanded={rootSkillsExpanded}
|
||||
onClick={() => toggleRootSkills(key)}
|
||||
>
|
||||
{rootSkillsExpanded ? t("caps.hideSkills") : t("caps.showSkills")}
|
||||
</button>
|
||||
)}
|
||||
{canRemoveRoot && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => setConfirmingRoot(root.dir)}>
|
||||
{t("caps.skillRootRemove")}
|
||||
</button>
|
||||
<InlineConfirmButton
|
||||
label={t("caps.skillRootRemove")}
|
||||
confirmLabel={t("caps.skillRootConfirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={() => onRemove(root.dir)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{rootSkillsExpanded && rootSkills.length > 0 && (
|
||||
@@ -577,14 +589,12 @@ function ServerGroup({
|
||||
expanded,
|
||||
expandedTools,
|
||||
busy,
|
||||
confirming,
|
||||
editing,
|
||||
onConfirm,
|
||||
onCancelConfirm,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onRemove,
|
||||
onRetry,
|
||||
onConfirmClearAuth,
|
||||
onToggle,
|
||||
onSetTier,
|
||||
onUpdate,
|
||||
@@ -595,14 +605,12 @@ function ServerGroup({
|
||||
expanded: Set<string>;
|
||||
expandedTools: Set<string>;
|
||||
busy: boolean;
|
||||
confirming: string | null;
|
||||
editing: string | null;
|
||||
onConfirm: (name: string) => void;
|
||||
onCancelConfirm: () => void;
|
||||
onEdit: (name: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onRemove: (name: string) => void;
|
||||
onRetry: (name: string) => void;
|
||||
onConfirmClearAuth: (name: string) => void;
|
||||
onToggle: (name: string, on: boolean) => void;
|
||||
onSetTier: (name: string, tier: string) => void;
|
||||
onUpdate: (name: string, input: MCPServerInput) => void;
|
||||
@@ -619,14 +627,12 @@ function ServerGroup({
|
||||
expanded={expanded.has(s.name)}
|
||||
toolsExpanded={expandedTools.has(s.name)}
|
||||
busy={busy}
|
||||
confirming={confirming === s.name}
|
||||
editing={editing === s.name}
|
||||
onConfirm={() => onConfirm(s.name)}
|
||||
onCancelConfirm={onCancelConfirm}
|
||||
onEdit={() => onEdit(s.name)}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onRemove={() => onRemove(s.name)}
|
||||
onRetry={() => onRetry(s.name)}
|
||||
onConfirmClearAuth={() => onConfirmClearAuth(s.name)}
|
||||
onToggle={(on) => onToggle(s.name, on)}
|
||||
onSetTier={(tier) => onSetTier(s.name, tier)}
|
||||
onUpdate={(input) => onUpdate(s.name, input)}
|
||||
@@ -642,22 +648,20 @@ function FailedServersNotice({
|
||||
servers,
|
||||
expanded,
|
||||
busy,
|
||||
confirming,
|
||||
onToggle,
|
||||
onRetry,
|
||||
onConfirmClearAuth,
|
||||
onSetTier,
|
||||
onConfirm,
|
||||
onCancelConfirm,
|
||||
onRemove,
|
||||
}: {
|
||||
servers: ServerView[];
|
||||
expanded: Set<string>;
|
||||
busy: boolean;
|
||||
confirming: string | null;
|
||||
onToggle: (name: string) => void;
|
||||
onRetry: (name: string) => void;
|
||||
onConfirmClearAuth: (name: string) => void;
|
||||
onSetTier: (name: string, tier: string) => void;
|
||||
onConfirm: (name: string) => void;
|
||||
onCancelConfirm: () => void;
|
||||
onRemove: (name: string) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
return (
|
||||
@@ -673,10 +677,10 @@ function FailedServersNotice({
|
||||
const open = expanded.has(s.name);
|
||||
const error = s.error || t("caps.failed");
|
||||
const actionLabel = serverActionLabel(s, t);
|
||||
const authURL = authURLForServer(s);
|
||||
const canConfigure = s.configured;
|
||||
const handlePrimaryAction = () => {
|
||||
if (isAuthFailure(error) && authURL) {
|
||||
openExternal(authURL);
|
||||
if (shouldOpenAuth(s)) {
|
||||
openExternal((s.authUrl || "").trim());
|
||||
return;
|
||||
}
|
||||
onRetry(s.name);
|
||||
@@ -687,36 +691,41 @@ function FailedServersNotice({
|
||||
<span className="cap-dot cap-dot--failed" />
|
||||
<div className="cap-failure__text">
|
||||
<div className="cap-failure__name">{s.name}</div>
|
||||
<div className="cap-failure__summary">{summarizeServerError(error, t)}</div>
|
||||
<div className="cap-failure__summary">{s.authStatus === "required" ? t("caps.authRequiredSummary") : summarizeServerError(error, t)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-failure__actions">
|
||||
{confirming === s.name ? (
|
||||
<>
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => onRemove(s.name)}>
|
||||
{t("caps.confirmRemove")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
|
||||
{open ? t("common.collapse") : t("caps.showLog")}
|
||||
</button>
|
||||
{!s.builtIn && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => onConfirm(s.name)}>
|
||||
{t("caps.remove")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
{canClearAuth(s) && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.clearAuth")}
|
||||
confirmLabel={t("caps.confirmClearAuth")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
onConfirm={() => onConfirmClearAuth(s.name)}
|
||||
/>
|
||||
)}
|
||||
<button className="btn btn--small" onClick={() => onToggle(s.name)} aria-expanded={open}>
|
||||
{open ? t("common.collapse") : t("caps.showLog")}
|
||||
</button>
|
||||
{!s.builtIn && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.remove")}
|
||||
confirmLabel={t("caps.confirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={() => onConfirm(s.name)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{canConfigure && (
|
||||
<div className="cap-failure__mode">
|
||||
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={(tier) => onSetTier(s.name, tier)} />
|
||||
</div>
|
||||
)}
|
||||
{open && (
|
||||
<div className="cap-failure__logbox">
|
||||
<div className="cap-failure__logbar">
|
||||
@@ -741,14 +750,12 @@ function ServerRow({
|
||||
expanded,
|
||||
toolsExpanded,
|
||||
busy,
|
||||
confirming,
|
||||
editing,
|
||||
onConfirm,
|
||||
onCancelConfirm,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onRemove,
|
||||
onRetry,
|
||||
onConfirmClearAuth,
|
||||
onToggle,
|
||||
onSetTier,
|
||||
onUpdate,
|
||||
@@ -759,14 +766,12 @@ function ServerRow({
|
||||
expanded: boolean;
|
||||
toolsExpanded: boolean;
|
||||
busy: boolean;
|
||||
confirming: boolean;
|
||||
editing: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancelConfirm: () => void;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onRemove: () => void;
|
||||
onRetry: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
onToggle: (on: boolean) => void;
|
||||
onSetTier: (tier: string) => void;
|
||||
onUpdate: (input: MCPServerInput) => void;
|
||||
@@ -776,7 +781,7 @@ function ServerRow({
|
||||
const t = useT();
|
||||
const actionLabel = serverActionLabel(s, t);
|
||||
const tools = s.toolList ?? [];
|
||||
const sub =
|
||||
let sub =
|
||||
s.status === "failed"
|
||||
? s.error || t("caps.failed")
|
||||
: s.status === "initializing"
|
||||
@@ -788,7 +793,17 @@ function ServerRow({
|
||||
? t("caps.disabledAutoStart")
|
||||
: t("caps.disabled")
|
||||
: t("caps.counts", { tools: s.tools, prompts: s.prompts, resources: s.resources });
|
||||
if (s.authStatus === "possible" && s.status !== "failed") {
|
||||
sub = `${sub} · ${t("caps.authPossibleShort")}`;
|
||||
}
|
||||
const enabled = s.status === "connected" || s.status === "deferred" || s.status === "initializing";
|
||||
const handlePrimaryAction = () => {
|
||||
if (shouldOpenAuth(s)) {
|
||||
openExternal((s.authUrl || "").trim());
|
||||
return;
|
||||
}
|
||||
onRetry();
|
||||
};
|
||||
return (
|
||||
<div className={`cap-server-entry${s.status === "disabled" ? " cap-server-entry--disabled" : ""}`}>
|
||||
<Tooltip label={s.error} disabled={!s.error} fill block>
|
||||
@@ -807,49 +822,29 @@ function ServerRow({
|
||||
<div className="cap-row__head">
|
||||
<span className="cap-row__name">{s.name}</span>
|
||||
<span className="cap-row__transport">{s.transport}</span>
|
||||
{s.builtIn && <span className="cap-row__builtin">{t("caps.builtIn")}</span>}
|
||||
</div>
|
||||
<div className="cap-row__sub">{sub}</div>
|
||||
</div>
|
||||
<div className="cap-row__actions">
|
||||
{confirming ? (
|
||||
<>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
|
||||
{t("caps.confirmRemove")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</>
|
||||
{s.status === "failed" ? (
|
||||
<button className="btn btn--small" disabled={busy} onClick={handlePrimaryAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : s.status === "initializing" ? (
|
||||
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
|
||||
) : (
|
||||
<>
|
||||
{s.status === "failed" ? (
|
||||
<button className="btn btn--small" disabled={busy} onClick={onRetry}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : s.status === "initializing" ? (
|
||||
<span className="cap-row__pending">{t("caps.initializingShort")}</span>
|
||||
) : s.builtIn ? (
|
||||
s.status === "disabled" ? (
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => onToggle(true)}>
|
||||
{t("caps.enable")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="cap-row__builtin">{t("caps.builtIn")}</span>
|
||||
)
|
||||
) : (
|
||||
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
<Tooltip label={enabled ? t("caps.disable") : t("caps.enable")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggle(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -859,11 +854,9 @@ function ServerRow({
|
||||
s={s}
|
||||
tools={tools}
|
||||
busy={busy}
|
||||
confirming={confirming}
|
||||
onConfirm={onConfirm}
|
||||
onCancelConfirm={onCancelConfirm}
|
||||
onRemove={onRemove}
|
||||
onConnectNow={onRetry}
|
||||
onConfirmClearAuth={onConfirmClearAuth}
|
||||
onSetTier={onSetTier}
|
||||
toolsExpanded={toolsExpanded}
|
||||
editing={editing}
|
||||
@@ -881,11 +874,9 @@ function ServerDetails({
|
||||
s,
|
||||
tools,
|
||||
busy,
|
||||
confirming,
|
||||
onConfirm,
|
||||
onCancelConfirm,
|
||||
onRemove,
|
||||
onConnectNow,
|
||||
onConfirmClearAuth,
|
||||
onSetTier,
|
||||
toolsExpanded,
|
||||
editing,
|
||||
@@ -897,11 +888,9 @@ function ServerDetails({
|
||||
s: ServerView;
|
||||
tools: ServerView["toolList"];
|
||||
busy: boolean;
|
||||
confirming: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancelConfirm: () => void;
|
||||
onRemove: () => void;
|
||||
onConnectNow: () => void;
|
||||
onConfirmClearAuth: () => void;
|
||||
onSetTier: (tier: string) => void;
|
||||
toolsExpanded: boolean;
|
||||
editing: boolean;
|
||||
@@ -912,10 +901,13 @@ function ServerDetails({
|
||||
}) {
|
||||
const t = useT();
|
||||
const command = serverCommand(s);
|
||||
const canConfigure = s.configured && !s.builtIn;
|
||||
const canConnectNow = !s.builtIn && (s.status === "deferred" || s.status === "disabled");
|
||||
const canConfigure = s.configured;
|
||||
const canEditConfig = s.configured && !s.builtIn;
|
||||
const canConnectNow = s.status === "deferred" || s.status === "disabled";
|
||||
const canShowTools = (s.tools ?? 0) > 0 || (tools?.length ?? 0) > 0;
|
||||
if (editing && canConfigure) {
|
||||
const showClearAuth = canClearAuth(s);
|
||||
const authLabel = serverAuthLabel(s, t);
|
||||
if (editing && canEditConfig) {
|
||||
return (
|
||||
<div className="cap-server-details">
|
||||
<EditServerForm s={s} busy={busy} onCancel={onCancelEdit} onSave={onUpdate} />
|
||||
@@ -933,6 +925,12 @@ function ServerDetails({
|
||||
<span className="cap-detail__label">{t("caps.transport")}</span>
|
||||
<span className="cap-detail__value">{s.transport}</span>
|
||||
</div>
|
||||
{authLabel && (
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.auth")}</span>
|
||||
<span className="cap-detail__value">{authLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{canConfigure && (
|
||||
<AutoConnectControls tier={s.tier || "lazy"} busy={busy} onTierChange={onSetTier} />
|
||||
)}
|
||||
@@ -960,27 +958,30 @@ function ServerDetails({
|
||||
{toolsExpanded ? t("caps.hideTools") : t("caps.showTools")}
|
||||
</button>
|
||||
)}
|
||||
{canConfigure && (
|
||||
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
|
||||
{t("caps.editConfig")}
|
||||
</button>
|
||||
{showClearAuth && (
|
||||
<InlineConfirmButton
|
||||
label={t("caps.clearAuth")}
|
||||
confirmLabel={t("caps.confirmClearAuth")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
onConfirm={onConfirmClearAuth}
|
||||
/>
|
||||
)}
|
||||
{canConfigure &&
|
||||
(confirming ? (
|
||||
<>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onRemove}>
|
||||
{t("caps.confirmRemove")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onCancelConfirm}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<div className="cap-confirm-hint">{t("caps.removeHint")}</div>
|
||||
</>
|
||||
) : (
|
||||
<button className="btn btn--small" disabled={busy} onClick={onConfirm}>
|
||||
{t("caps.remove")}
|
||||
{canEditConfig && (
|
||||
<>
|
||||
<button className="btn btn--small" disabled={busy} onClick={onEdit}>
|
||||
{t("caps.editConfig")}
|
||||
</button>
|
||||
))}
|
||||
<InlineConfirmButton
|
||||
label={t("caps.remove")}
|
||||
confirmLabel={t("caps.confirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{toolsExpanded && (
|
||||
tools && tools.length > 0 ? (
|
||||
@@ -1165,6 +1166,7 @@ function serverStatusLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
case "disabled":
|
||||
return s.configured && !s.autoStart ? t("caps.disabledAutoStart") : t("caps.disabled");
|
||||
case "failed":
|
||||
if (s.authStatus === "required") return t("caps.authRequired");
|
||||
return t("caps.failed");
|
||||
default:
|
||||
return s.status;
|
||||
@@ -1186,21 +1188,9 @@ function summarizeServerError(error: string, t: ReturnType<typeof useT>): string
|
||||
return summary.length > 180 ? `${summary.slice(0, 176).trim()}…` : summary;
|
||||
}
|
||||
|
||||
function isAuthFailure(error: string): boolean {
|
||||
const err = error.toLowerCase();
|
||||
return err.includes("401") || err.includes("403") || err.includes("unauthorized") || err.includes("forbidden") || err.includes("invalid token") || err.includes("login required");
|
||||
}
|
||||
|
||||
function authURLForServer(s: ServerView): string {
|
||||
if (!isAuthFailure(s.error || "")) return "";
|
||||
if (s.transport !== "http" && s.transport !== "sse") return "";
|
||||
const url = (s.url || "").trim();
|
||||
return /^https?:\/\//i.test(url) ? url : "";
|
||||
}
|
||||
|
||||
function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
const err = (s.error || "").toLowerCase();
|
||||
if (isAuthFailure(err)) return t("caps.reauthorize");
|
||||
if (shouldOpenAuth(s)) return t("caps.reauthorize");
|
||||
if (
|
||||
err.includes("command not found") ||
|
||||
err.includes("executable file not found") ||
|
||||
@@ -1212,38 +1202,76 @@ function serverActionLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
return t("caps.retry");
|
||||
}
|
||||
|
||||
function serverAuthLabel(s: ServerView, t: ReturnType<typeof useT>): string {
|
||||
if (s.authStatus === "required") return t("caps.authRequired");
|
||||
if (s.authStatus === "possible") return t("caps.authPossible");
|
||||
return "";
|
||||
}
|
||||
|
||||
function shouldOpenAuth(s: ServerView): boolean {
|
||||
const url = (s.authUrl || "").trim();
|
||||
return s.authStatus === "required" && /^https?:\/\//i.test(url);
|
||||
}
|
||||
|
||||
function canClearAuth(s: ServerView): boolean {
|
||||
if (!s.configured || s.builtIn) return false;
|
||||
return Boolean(s.authConfigured || s.authStatus === "required" || s.authStatus === "possible" || isRemoteTransport(s.transport));
|
||||
}
|
||||
|
||||
function isRemoteTransport(transport?: string): boolean {
|
||||
const value = (transport || "").trim().toLowerCase();
|
||||
return value === "http" || value === "streamable-http" || value === "sse";
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
skill,
|
||||
busy,
|
||||
expanded,
|
||||
onToggle,
|
||||
onToggleEnabled,
|
||||
}: {
|
||||
skill: SkillView;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const summary = summarizeSkillDescription(skill.description);
|
||||
const canExpand = summary !== skill.description;
|
||||
return (
|
||||
<button
|
||||
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}`}
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
<div
|
||||
className={`cap-skill-card${expanded ? " cap-skill-card--expanded" : ""}${canExpand ? " cap-skill-card--expandable" : ""}${!skill.enabled ? " cap-skill-card--disabled" : ""}`}
|
||||
>
|
||||
<div className="cap-skill-card__head">
|
||||
<span className="cap-skill-card__icon">/</span>
|
||||
<span className="cap-skill-card__main">
|
||||
<span className="cap-skill-card__command">{skill.name}</span>
|
||||
<span className="cap-skill-card__badges">
|
||||
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
|
||||
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
|
||||
<div className="cap-skill-card__top">
|
||||
<button className="cap-skill-card__toggle" type="button" onClick={onToggle} aria-expanded={expanded}>
|
||||
<span className="cap-skill-card__head">
|
||||
<span className="cap-skill-card__icon">/</span>
|
||||
<span className="cap-skill-card__main">
|
||||
<span className="cap-skill-card__command">{skill.name}</span>
|
||||
<span className="cap-skill-card__badges">
|
||||
<span className={`cap-skill-badge cap-skill-badge--${skill.scope}`}>{skillScopeLabel(skill.scope, t)}</span>
|
||||
{skill.runAs === "subagent" && <span className="cap-skill-badge cap-skill-badge--run">{t("caps.subagent")}</span>}
|
||||
{!skill.enabled && <span className="cap-skill-badge cap-skill-badge--off">{t("caps.skillDisabled")}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip label={skill.enabled ? t("caps.disableSkill") : t("caps.enableSkill")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skill.enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="cap-skill-card__desc">{expanded ? skill.description : summary}</div>
|
||||
{canExpand && <div className="cap-skill-card__more">{expanded ? t("common.collapse") : t("common.expand")}</div>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, Search, Square, Trash2, X } from "lucide-react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";
|
||||
import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, Search, Square, Trash2, X, Zap } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app, onFilesDropped } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import type { CommandInfo, ComposerInsertRequest, DirEntry, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
|
||||
import type { CommandInfo, ComposerInsertRequest, DirEntry, EffortInfo, Mode, SlashArgItem, SlashArgsResult, WorkspaceView } from "../lib/types";
|
||||
import {
|
||||
formatWorkspaceReference,
|
||||
parseWorkspaceReference,
|
||||
@@ -14,7 +15,10 @@ import {
|
||||
import { SlashMenu } from "./SlashMenu";
|
||||
import { ArgMenu } from "./ArgMenu";
|
||||
import { FileMenu } from "./FileMenu";
|
||||
import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
interface Attachment {
|
||||
path: string;
|
||||
@@ -76,6 +80,27 @@ function loadComposerHeight(): number | null {
|
||||
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
function useTick(on: boolean): number {
|
||||
const [, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
const id = window.setInterval(() => setN((n) => n + 1), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [on]);
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function isImeKeyEvent(
|
||||
e: KeyboardEvent<HTMLTextAreaElement>,
|
||||
composing: boolean,
|
||||
@@ -97,31 +122,56 @@ export function Composer({
|
||||
running,
|
||||
mode,
|
||||
cwd,
|
||||
modelLabel,
|
||||
tabId,
|
||||
effort,
|
||||
onSend,
|
||||
onCancel,
|
||||
onCycleMode,
|
||||
onSetMode,
|
||||
onSwitchModel,
|
||||
onSetEffort,
|
||||
onPickFolder,
|
||||
onRemoveWorkspace,
|
||||
insertRequest,
|
||||
disabled,
|
||||
decisionPending = false,
|
||||
ready,
|
||||
turnStartAt,
|
||||
turnTokens,
|
||||
retry,
|
||||
workspaceRefreshSignal,
|
||||
}: {
|
||||
running: boolean;
|
||||
mode: Mode;
|
||||
cwd?: string;
|
||||
modelLabel: string;
|
||||
tabId?: string;
|
||||
effort?: EffortInfo;
|
||||
onSend: (displayText: string, submitText?: string) => void;
|
||||
// Returns the un-sent text when cancelling before the server replied (so it can
|
||||
// be restored to the input); undefined for a normal cancel.
|
||||
onCancel: () => string | undefined;
|
||||
onCycleMode: () => void;
|
||||
onSetMode: (mode: Mode) => void;
|
||||
onSwitchModel: (name: string) => void;
|
||||
onSetEffort: (level: string) => void;
|
||||
onPickFolder: (path?: string) => Promise<string>;
|
||||
onRemoveWorkspace: (path: string) => Promise<void>;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
disabled?: boolean;
|
||||
decisionPending?: boolean;
|
||||
// ready/cwd re-trigger the command fetch: Commands() returns only built-ins
|
||||
// until boot.Build finishes (the controller, hence skills/custom/MCP, is nil
|
||||
// before then), and the available set changes when the workspace switches.
|
||||
ready?: boolean;
|
||||
turnStartAt?: number;
|
||||
turnTokens?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
workspaceRefreshSignal?: number;
|
||||
}) {
|
||||
const t = useT();
|
||||
const { t, locale } = useI18n();
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
|
||||
@@ -141,7 +191,6 @@ export function Composer({
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
const composerCardRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
|
||||
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||
const wasRunning = useRef(running);
|
||||
const composingRef = useRef(false);
|
||||
const lastCompositionEndAt = useRef(0);
|
||||
@@ -160,7 +209,7 @@ export function Composer({
|
||||
// --- slash commands (whole-input "/token") ---
|
||||
const [commands, setCommands] = useState<CommandInfo[]>([]);
|
||||
useEffect(() => {
|
||||
app.Commands().then(setCommands).catch(() => {});
|
||||
app.Commands().then((next) => setCommands(asArray(next))).catch(() => {});
|
||||
}, [ready, cwd]);
|
||||
|
||||
const slashQuery = useMemo(() => {
|
||||
@@ -174,32 +223,41 @@ export function Composer({
|
||||
|
||||
// --- slash argument completion ("/cmd <args>") --- mirrors the CLI: once past
|
||||
// the command word, the backend suggests sub-commands (/skill → list/show/…,
|
||||
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs.
|
||||
// /mcp → add/remove, /model → refs). Fetched from app.SlashArgs. Debounced
|
||||
// by 120ms so rapid typing doesn't flood the backend with IPC calls — the
|
||||
// menu only updates after the user pauses.
|
||||
const [argRes, setArgRes] = useState<SlashArgsResult | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
useEffect(() => {
|
||||
if (!text.startsWith("/") || !/\s/.test(text)) {
|
||||
setArgRes(null);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.SlashArgs(text)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
// Drop suggestions that wouldn't change the input — the token is already
|
||||
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
|
||||
// lingers on a complete command and Enter keeps "accepting" a no-op
|
||||
// instead of sending. (Defense-in-depth: the backend filters these too.)
|
||||
// r.items can arrive as null (an empty Go slice serializes to JSON null),
|
||||
// so guard before filtering — otherwise the throw is swallowed and the
|
||||
// stale menu from the previous keystroke lingers (the /skill list bug).
|
||||
const useful = (r.items ?? []).filter((it) => text.slice(0, r.from) + it.insert !== text);
|
||||
setArgRes(useful.length > 0 ? { items: useful, from: r.from } : null);
|
||||
setActive(0);
|
||||
})
|
||||
.catch(() => {});
|
||||
clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
app
|
||||
.SlashArgs(text)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
// Drop suggestions that wouldn't change the input — the token is already
|
||||
// fully typed (e.g. "/skill list" offering "list"). Otherwise the menu
|
||||
// lingers on a complete command and Enter keeps "accepting" a no-op
|
||||
// instead of sending. (Defense-in-depth: the backend filters these too.)
|
||||
// r.items can arrive as null (an empty Go slice serializes to JSON null),
|
||||
// so guard before filtering — otherwise the throw is swallowed and the
|
||||
// stale menu from the previous keystroke lingers (the /skill list bug).
|
||||
const items = asArray(r?.items);
|
||||
const from = r?.from ?? 0;
|
||||
const useful = items.filter((it) => text.slice(0, from) + it.insert !== text);
|
||||
setArgRes(useful.length > 0 ? { items: useful, from } : null);
|
||||
setActive(0);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 120);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
@@ -237,7 +295,7 @@ export function Composer({
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((es) => {
|
||||
const list = es ?? [];
|
||||
const list = asArray(es);
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
})
|
||||
@@ -575,23 +633,12 @@ export function Composer({
|
||||
}, [cwd]);
|
||||
|
||||
const loadWorkspaces = () => {
|
||||
app.ListWorkspaces().then(setWorkspaces).catch(() => setWorkspaces([]));
|
||||
app.ListWorkspaces().then((next) => setWorkspaces(asArray(next))).catch(() => setWorkspaces([]));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMenuOpen) loadWorkspaces();
|
||||
}, [workspaceMenuOpen, cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceMenuOpen) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (workspaceAnchorRef.current?.contains(target) || workspaceMenuRef.current?.contains(target)) return;
|
||||
setWorkspaceMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", close);
|
||||
return () => document.removeEventListener("mousedown", close);
|
||||
}, [workspaceMenuOpen]);
|
||||
}, [workspaceMenuOpen, cwd, workspaceRefreshSignal]);
|
||||
|
||||
const filteredWorkspaces = useMemo(() => {
|
||||
const q = workspaceQuery.trim().toLowerCase();
|
||||
@@ -607,6 +654,11 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const removeWorkspace = async (path: string) => {
|
||||
await onRemoveWorkspace(path);
|
||||
setWorkspaces((prev) => prev.filter((w) => w.path !== path));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setComposerHeight((height) => (height === null ? null : clampComposerHeight(height)));
|
||||
window.addEventListener("resize", onResize);
|
||||
@@ -717,18 +769,48 @@ export function Composer({
|
||||
}
|
||||
// Esc interrupts the in-flight turn (matches the Stop button's hint), and
|
||||
// restores the text if the server hadn't replied yet.
|
||||
if (e.key === "Escape" && running) {
|
||||
if (e.key === "Escape" && running && !decisionPending) {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
|
||||
const modeOptions: Array<{ id: Mode; label: string; icon: ReactNode }> = [
|
||||
{ id: "normal", label: "auto", icon: <Zap size={13} /> },
|
||||
{ id: "plan", label: "plan", icon: <List size={13} /> },
|
||||
{ id: "yolo", label: "yolo", icon: <AlertTriangle size={13} /> },
|
||||
];
|
||||
const runActivity = retry
|
||||
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
|
||||
: running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
const hasWorkspace = Boolean(cwd);
|
||||
const hasEffort = Boolean(effort?.supported);
|
||||
const composerMetaClass = [
|
||||
"composer-meta",
|
||||
hasWorkspace ? "composer-meta--has-workspace" : "composer-meta--no-workspace",
|
||||
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
|
||||
].join(" ");
|
||||
|
||||
return (
|
||||
<div className="composer-wrap" style={{ "--wails-drop-target": "drop" } as CSSProperties}>
|
||||
{workspaceMenuOpen && cwd && (
|
||||
<div className="workspace-switcher" ref={workspaceMenuRef}>
|
||||
<div
|
||||
className={`composer-wrap${decisionPending ? " composer-wrap--decision-pending" : ""}`}
|
||||
style={{ "--wails-drop-target": "drop" } as CSSProperties}
|
||||
>
|
||||
<AnchoredPopover
|
||||
open={workspaceMenuOpen && !!cwd}
|
||||
anchorRef={workspaceAnchorRef}
|
||||
onClose={() => setWorkspaceMenuOpen(false)}
|
||||
className="workspace-switcher workspace-switcher--portal"
|
||||
>
|
||||
<label className="workspace-switcher__search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
@@ -743,9 +825,10 @@ export function Composer({
|
||||
</label>
|
||||
<div className="workspace-switcher__list">
|
||||
{filteredWorkspaces.map((w) => (
|
||||
<Tooltip key={w.path} label={w.path} fill>
|
||||
<div className="workspace-switcher__row" key={w.path}>
|
||||
<button
|
||||
className="workspace-switcher__item"
|
||||
className={`workspace-switcher__item${w.current ? " workspace-switcher__item--current" : ""}`}
|
||||
title={w.path}
|
||||
onClick={() => {
|
||||
if (w.current) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
@@ -758,7 +841,20 @@ export function Composer({
|
||||
<span>{w.name}</span>
|
||||
{w.current && <Check size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
className="workspace-switcher__remove"
|
||||
type="button"
|
||||
aria-label={t("composer.removeProject")}
|
||||
title={t("composer.removeProject")}
|
||||
disabled={running}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void removeWorkspace(w.path);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filteredWorkspaces.length === 0 && <div className="workspace-switcher__empty">{t("composer.noProjectMatches")}</div>}
|
||||
</div>
|
||||
@@ -768,8 +864,7 @@ export function Composer({
|
||||
<span>{t("composer.addProject")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnchoredPopover>
|
||||
{menuMode === "slash" && (
|
||||
<SlashMenu items={slashMatches} activeIndex={active} onPick={pickCommand} onHover={setActive} />
|
||||
)}
|
||||
@@ -777,6 +872,35 @@ export function Composer({
|
||||
<ArgMenu items={argRes.items} activeIndex={active} onPick={pickArg} onHover={setActive} />
|
||||
)}
|
||||
{menuMode === "at" && <FileMenu items={atMatches} activeIndex={active} onPick={pickEntry} onHover={setActive} />}
|
||||
<div className="composer-toolbar">
|
||||
<div className="composer-modebar" role="toolbar" aria-label={t("composer.modeTitle")}>
|
||||
{modeOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--${option.id}${mode === option.id ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => onSetMode(option.id)}
|
||||
aria-pressed={mode === option.id}
|
||||
disabled={disabled || running}
|
||||
>
|
||||
{option.icon}
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{runActivity && (
|
||||
<div className="composer-runstatus" role="status" aria-live="polite">
|
||||
<span className="composer-runstatus__dot" />
|
||||
<span className="composer-runstatus__text">{runActivity}</span>
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button className="composer-runstatus__stop" type="button" onClick={handleCancel} disabled={decisionPending}>
|
||||
<Square size={10} fill="currentColor" />
|
||||
<span>{t("composer.stopShort")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(attachments.length > 0 || workspaceRefs.length > 0) && (
|
||||
<div className="composer-context" aria-label={t("composer.contextItems")}>
|
||||
{attachments.map((a) => (
|
||||
@@ -865,12 +989,12 @@ export function Composer({
|
||||
onDoubleClick={resetComposerHeight}
|
||||
/>
|
||||
<div
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}`}
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled ? " composer--disabled" : ""}${text.trimStart().startsWith("!") ? " composer--shell" : ""}`}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
>
|
||||
<span className="composer__caret">›</span>
|
||||
<span className="composer__caret">{text.trimStart().startsWith("!") ? "$" : "›"}</span>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
className="composer__input"
|
||||
@@ -893,13 +1017,7 @@ export function Composer({
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{running ? (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button className="composer__btn composer__btn--stop" onClick={handleCancel}>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
{!running && (
|
||||
<Tooltip label={t("composer.send")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--send"
|
||||
@@ -911,34 +1029,32 @@ export function Composer({
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-meta">
|
||||
<div className={composerMetaClass}>
|
||||
{cwd && (
|
||||
<div className="composer-workspace-wrap" ref={workspaceAnchorRef}>
|
||||
<Tooltip label={running ? t("common.busyHint") : t("status.switchFolder", { cwd })}>
|
||||
<button
|
||||
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
|
||||
onClick={() => {
|
||||
if (!running) setWorkspaceMenuOpen((open) => !open);
|
||||
}}
|
||||
disabled={running}
|
||||
>
|
||||
<FolderGit2 size={13} />
|
||||
<span>{workspaceName}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="composer-meta__control composer-meta__control--workspace composer-workspace-wrap" ref={workspaceAnchorRef}>
|
||||
<button
|
||||
className={`composer__workspace${workspaceMenuOpen ? " composer__workspace--open" : ""}`}
|
||||
onClick={() => {
|
||||
if (!running) setWorkspaceMenuOpen((open) => !open);
|
||||
}}
|
||||
disabled={running}
|
||||
>
|
||||
<FolderGit2 size={13} />
|
||||
<span>{workspaceName}</span>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip label={t("composer.modeTitle")}>
|
||||
<button
|
||||
className={`composer__mode composer__mode--${mode}`}
|
||||
onClick={onCycleMode}
|
||||
>
|
||||
<span className="composer__mode-dot" />
|
||||
{mode === "yolo" ? t("composer.modeYolo") : mode === "plan" ? t("composer.modePlan") : t("composer.modeNormal")}
|
||||
<span className="composer__mode-hint">{t("composer.modeHint")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="composer-meta__params">
|
||||
<div className="composer-meta__control composer-meta__control--model">
|
||||
<ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} />
|
||||
</div>
|
||||
{effort?.supported && (
|
||||
<div className="composer-meta__control composer-meta__control--effort">
|
||||
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export type ContextMenuPoint = { left: number; top: number };
|
||||
|
||||
export type ContextMenuItem =
|
||||
| {
|
||||
type?: "item";
|
||||
key: string;
|
||||
icon?: ReactNode;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| {
|
||||
type: "separator";
|
||||
key: string;
|
||||
};
|
||||
|
||||
const EDGE_GAP = 8;
|
||||
|
||||
function clampMenuPoint(left: number, top: number, width: number, height: number): ContextMenuPoint {
|
||||
if (typeof window === "undefined") return { left, top };
|
||||
return {
|
||||
left: Math.min(Math.max(EDGE_GAP, left), Math.max(EDGE_GAP, window.innerWidth - width - EDGE_GAP)),
|
||||
top: Math.min(Math.max(EDGE_GAP, top), Math.max(EDGE_GAP, window.innerHeight - height - EDGE_GAP)),
|
||||
};
|
||||
}
|
||||
|
||||
export function contextMenuPointFromEvent(
|
||||
event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>,
|
||||
): ContextMenuPoint {
|
||||
if ("clientX" in event && event.clientX > 0 && event.clientY > 0) {
|
||||
return { left: event.clientX, top: event.clientY };
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
return { left: rect.left + 12, top: rect.bottom + 6 };
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
open,
|
||||
point,
|
||||
items,
|
||||
onClose,
|
||||
minWidth = 180,
|
||||
ariaLabel = "Context menu",
|
||||
}: {
|
||||
open: boolean;
|
||||
point: ContextMenuPoint | null;
|
||||
items: ContextMenuItem[];
|
||||
onClose: () => void;
|
||||
minWidth?: number;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<ContextMenuPoint | null>(point);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !point) return;
|
||||
const rect = menuRef.current?.getBoundingClientRect();
|
||||
if (!rect) {
|
||||
setPosition(point);
|
||||
return;
|
||||
}
|
||||
setPosition(clampMenuPoint(point.left, point.top, rect.width, rect.height));
|
||||
}, [open, point, items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeOnOutsidePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Node && menuRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
const close = () => onClose();
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointerDown, true);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("scroll", close, true);
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointerDown, true);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("scroll", close, true);
|
||||
window.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open || !point || !position) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="context-menu"
|
||||
role="menu"
|
||||
aria-label={ariaLabel}
|
||||
style={{ left: position.left, top: position.top, minWidth }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
if (item.type === "separator") {
|
||||
return <div key={item.key} className="context-menu__separator" role="separator" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={item.disabled}
|
||||
className={`context-menu__item${item.danger ? " context-menu__item--danger" : ""}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!item.disabled) item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
// ContextPanel shows the active tab's context gauge, token usage, read files,
|
||||
// and workspace changes. All visible text is routed through the i18n dictionary.
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, FileText, Search } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { DictKey } from "../locales/en";
|
||||
import type { ContextInfo, ContextPanelInfo, WireUsage } from "../lib/types";
|
||||
|
||||
interface ContextPanelProps {
|
||||
tabId?: string;
|
||||
context?: ContextInfo;
|
||||
usage?: WireUsage;
|
||||
sessionCost?: number;
|
||||
sessionCurrency?: string;
|
||||
scopeLabel?: string;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
type ContextDetail = "read" | "changed";
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function fmtTime(ms?: number): string {
|
||||
if (!ms) return "";
|
||||
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number, t: Translator): string {
|
||||
if (ms <= 0) return "-";
|
||||
const totalSeconds = Math.max(1, Math.round(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
if (minutes <= 0) return t("context.durationSeconds", { seconds });
|
||||
return t("context.durationMinutesSeconds", { minutes, seconds });
|
||||
}
|
||||
|
||||
function currencySymbol(currency?: string): string {
|
||||
const value = (currency || "¥").trim();
|
||||
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
|
||||
if (/^(usd|dollar)$/i.test(value)) return "$";
|
||||
return value || "¥";
|
||||
}
|
||||
|
||||
function fmtMoney(amount: number, currency?: string): string {
|
||||
if (amount <= 0) return "-";
|
||||
const symbol = currencySymbol(currency);
|
||||
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
tone: "good" | "notice" | "warn";
|
||||
labelKey: DictKey;
|
||||
bodyKey: DictKey;
|
||||
vars: Record<string, string | number>;
|
||||
}
|
||||
|
||||
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
|
||||
if (usagePct >= 85) {
|
||||
return {
|
||||
tone: "warn",
|
||||
labelKey: "context.healthNearLimit",
|
||||
bodyKey: "context.healthNearLimitBody",
|
||||
vars: { pct: usagePct },
|
||||
};
|
||||
}
|
||||
if (readCount >= 8) {
|
||||
return {
|
||||
tone: "notice",
|
||||
labelKey: "context.healthManyFiles",
|
||||
bodyKey: "context.healthManyFilesBody",
|
||||
vars: { count: readCount },
|
||||
};
|
||||
}
|
||||
if (cachePct > 0 && cachePct < 50) {
|
||||
return {
|
||||
tone: "notice",
|
||||
labelKey: "context.healthLowCache",
|
||||
bodyKey: "context.healthLowCacheBody",
|
||||
vars: { pct: cachePct },
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "good",
|
||||
labelKey: "context.healthGood",
|
||||
bodyKey: "context.healthGoodBody",
|
||||
vars: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function ContextPanel({ tabId, context, usage, sessionCost, sessionCurrency, scopeLabel, refreshKey }: ContextPanelProps) {
|
||||
const t = useT();
|
||||
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
|
||||
const [detailView, setDetailView] = useState<ContextDetail | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tabId) return;
|
||||
try {
|
||||
setInfo(await app.ContextPanel(tabId));
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
}, [tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => void refresh(), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
|
||||
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
|
||||
const promptTokens = usage?.promptTokens && usage.promptTokens > 0 ? usage.promptTokens : info?.promptTokens ?? 0;
|
||||
const completionTokens = usage?.completionTokens && usage.completionTokens > 0 ? usage.completionTokens : info?.completionTokens ?? 0;
|
||||
const reasoningTokens = usage?.reasoningTokens && usage.reasoningTokens > 0 ? usage.reasoningTokens : info?.reasoningTokens ?? 0;
|
||||
const cacheHitTokens = usage?.cacheHitTokens && usage.cacheHitTokens > 0 ? usage.cacheHitTokens : info?.cacheHitTokens ?? 0;
|
||||
const cacheMissTokens = usage?.cacheMissTokens && usage.cacheMissTokens > 0 ? usage.cacheMissTokens : info?.cacheMissTokens ?? 0;
|
||||
const cost = sessionCost && sessionCost > 0 ? sessionCost : info?.sessionCost ?? info?.sessionCostUsd ?? 0;
|
||||
const currency = sessionCurrency || info?.sessionCurrency || usage?.currency || "¥";
|
||||
const readFiles = asArray(info?.readFiles);
|
||||
const changedFiles = asArray(info?.changedFiles);
|
||||
|
||||
const usagePct = windowTokens > 0 ? Math.round((usedTokens / windowTokens) * 100) : 0;
|
||||
const cachePct = cacheHitTokens + cacheMissTokens > 0
|
||||
? Math.round((cacheHitTokens / (cacheHitTokens + cacheMissTokens)) * 100)
|
||||
: 0;
|
||||
const otherTokens = Math.max(0, usedTokens - promptTokens - completionTokens - reasoningTokens);
|
||||
const safeUsed = Math.max(usedTokens, 1);
|
||||
const promptPct = Math.min(100, (promptTokens / safeUsed) * usagePct);
|
||||
const completionPct = Math.min(100, promptPct + (completionTokens / safeUsed) * usagePct);
|
||||
const reasoningPct = Math.min(100, completionPct + (reasoningTokens / safeUsed) * usagePct);
|
||||
const otherPct = Math.min(100, reasoningPct + (otherTokens / safeUsed) * usagePct);
|
||||
const donutStyle = {
|
||||
background: `conic-gradient(#13a7a5 0 ${promptPct}%, #2f6df6 ${promptPct}% ${completionPct}%, #f97316 ${completionPct}% ${reasoningPct}%, var(--border) ${reasoningPct}% ${otherPct}%, var(--border-soft) ${otherPct}% 100%)`,
|
||||
};
|
||||
const eventTimes = [
|
||||
...readFiles.map((file) => file.time),
|
||||
...changedFiles.map((file) => file.latestTime ?? 0),
|
||||
].filter((time) => time > 0);
|
||||
const elapsed = eventTimes.length > 1 ? Math.max(...eventTimes) - Math.min(...eventTimes) : 0;
|
||||
const requestCount = Math.max(readFiles.length + changedFiles.length, 0);
|
||||
const readRows = readFiles.map((f, i) => ({
|
||||
key: `${f.path}-${i}`,
|
||||
path: f.path,
|
||||
meta: `#${f.turn}`,
|
||||
time: fmtTime(f.time),
|
||||
detail: f.limit ? `${f.offset ?? 0}-${(f.offset ?? 0) + f.limit}${f.truncated ? " truncated" : ""}` : "",
|
||||
}));
|
||||
const changedRows = changedFiles.map((f, i) => ({
|
||||
key: `${f.path}-${i}`,
|
||||
path: f.path,
|
||||
meta: f.gitStatus || asArray(f.sources).join(", ") || "changed",
|
||||
time: fmtTime(f.latestTime),
|
||||
detail: asArray(f.turns).length > 0 ? `T${asArray(f.turns).join(",")}` : "",
|
||||
}));
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filterRows = (rows: typeof readRows) => {
|
||||
if (!normalizedQuery) return rows;
|
||||
return rows.filter((row) =>
|
||||
`${row.path} ${row.meta} ${row.time} ${row.detail}`.toLowerCase().includes(normalizedQuery)
|
||||
);
|
||||
};
|
||||
const filteredReadRows = filterRows(readRows);
|
||||
const filteredChangedRows = filterRows(changedRows);
|
||||
const health = contextHealth(usagePct, cachePct, readRows.length);
|
||||
const detailRows = detailView === "changed" ? filteredChangedRows : filteredReadRows;
|
||||
const detailTitle = detailView === "changed" ? t("context.sessionChanges") : t("context.referencedFiles");
|
||||
const detailCount = detailView === "changed" ? changedRows.length : readRows.length;
|
||||
const detailEmpty = detailView === "changed" ? t("context.noChanges") : t("context.noReads");
|
||||
const detailPlaceholder = detailView === "changed" ? t("context.filterChanges") : t("context.filterReads");
|
||||
const detailNote = detailView === "changed"
|
||||
? t("context.changedNote", { count: detailCount })
|
||||
: t("context.readNote", { count: detailCount });
|
||||
|
||||
const openDetail = (next: ContextDetail) => {
|
||||
setDetailView(next);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
setDetailView(null);
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="context-panel__summary-head">
|
||||
<div className="context-panel__heading-main">
|
||||
<span>{detailView ? detailTitle : t("context.overview")}</span>
|
||||
<strong>{scopeLabel || t("context.scopeGlobal")}</strong>
|
||||
</div>
|
||||
{detailView && (
|
||||
<button className="context-panel__back" type="button" onClick={closeDetail}>
|
||||
<ArrowLeft size={13} />
|
||||
{t("rightDock.overview")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailView && (
|
||||
<label className="context-panel__search">
|
||||
<Search size={14} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={detailPlaceholder} />
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="context-panel__body">
|
||||
{detailView ? (
|
||||
<section className="context-panel__detail">
|
||||
<div className="context-panel__detail-note">{detailNote}</div>
|
||||
<FileTable
|
||||
empty={detailEmpty}
|
||||
rows={detailRows}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<section className="context-panel__overview">
|
||||
<section className="context-panel__usage">
|
||||
<div className="context-panel__donut" style={donutStyle}>
|
||||
<div className="context-panel__donut-core">
|
||||
<strong>{fmtTokens(usedTokens)}</strong>
|
||||
<span>/ {fmtTokens(windowTokens)} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__percent">{usagePct}%</div>
|
||||
<div className="context-panel__breakdown">
|
||||
<TokenLegend label={t("context.prompt")} value={promptTokens} color="prompt" />
|
||||
<TokenLegend label={t("context.completion")} value={completionTokens} color="completion" />
|
||||
<TokenLegend label={t("context.reasoning")} value={reasoningTokens} color="reasoning" />
|
||||
<TokenLegend label={t("context.other")} value={otherTokens} color="other" />
|
||||
<div className="context-panel__total">
|
||||
<span>{t("context.total")}</span>
|
||||
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.cacheHit")} value={cachePct > 0 ? `${cachePct}%` : "-"} tone="accent" />
|
||||
<MetricCard label={t("context.sessionCost")} value={fmtMoney(cost, currency)} />
|
||||
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
</div>
|
||||
</section>
|
||||
<div className={`context-panel__health context-panel__health--${health.tone}`}>
|
||||
<span>{t("context.health")}</span>
|
||||
<strong>{t(health.labelKey, health.vars)}</strong>
|
||||
<small>{t(health.bodyKey, health.vars)}</small>
|
||||
</div>
|
||||
<PreviewSection
|
||||
title={t("context.referencedFiles")}
|
||||
meta={t("context.readMeta", { count: readRows.length })}
|
||||
action={t("context.viewAll")}
|
||||
onAction={() => openDetail("read")}
|
||||
rows={readRows.slice(0, 3)}
|
||||
empty={t("context.noReads")}
|
||||
/>
|
||||
<PreviewSection
|
||||
title={t("context.sessionChanges")}
|
||||
meta={t("context.changedMeta", { count: changedRows.length })}
|
||||
action={t("context.viewAll")}
|
||||
onAction={() => openDetail("changed")}
|
||||
rows={changedRows.slice(0, 3)}
|
||||
empty={t("context.noChanges")}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="context-panel__scope">
|
||||
<FileText size={14} />
|
||||
<span>{scopeLabel || t("context.scopeGlobal")}</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({
|
||||
title,
|
||||
meta,
|
||||
action,
|
||||
onAction,
|
||||
rows,
|
||||
empty,
|
||||
}: {
|
||||
title: string;
|
||||
meta?: string;
|
||||
action: string;
|
||||
onAction: () => void;
|
||||
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="context-panel__preview">
|
||||
<header className="context-panel__preview-head">
|
||||
<h3>{title}</h3>
|
||||
{meta && <span>{meta}</span>}
|
||||
{rows.length > 0 && <button type="button" onClick={onAction}>{action}</button>}
|
||||
</header>
|
||||
<FileTable rows={rows} empty={empty} compact />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenLegend({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="context-panel__legend-row">
|
||||
<span className={`context-panel__legend-dot context-panel__legend-dot--${color}`} />
|
||||
<span>{label}</span>
|
||||
<strong>{value.toLocaleString()}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, tone }: { label: string; value: string; tone?: "accent" }) {
|
||||
return (
|
||||
<div className="context-panel__metric">
|
||||
<span>{label}</span>
|
||||
<strong className={tone === "accent" ? "context-panel__metric-accent" : ""}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTable({
|
||||
rows,
|
||||
empty,
|
||||
compact = false,
|
||||
}: {
|
||||
rows: Array<{ key: string; path: string; meta: string; time: string; detail: string }>;
|
||||
empty: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
if (rows.length === 0) return <div className="context-panel__empty">{empty}</div>;
|
||||
return (
|
||||
<div className={`context-panel__file-list${compact ? " context-panel__file-list--compact" : ""}`}>
|
||||
{rows.map((row) => (
|
||||
<div className="context-panel__file-row" key={row.key}>
|
||||
<span className="context-panel__file-main">
|
||||
<FileText size={14} />
|
||||
<span className="context-panel__file-copy">
|
||||
<span>{row.path}</span>
|
||||
{row.detail && <small>{row.detail}</small>}
|
||||
</span>
|
||||
</span>
|
||||
<span className="context-panel__file-meta">
|
||||
<span className="context-panel__file-turn">{row.meta}</span>
|
||||
{row.time && <span>{row.time}</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Check, ChevronsUpDown, Gauge } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { EffortInfo } from "../lib/types";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
export function EffortSwitcher({
|
||||
effort,
|
||||
@@ -14,7 +16,9 @@ export function EffortSwitcher({
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!effort?.supported || effort.levels.length === 0) return null;
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const levels = asArray(effort?.levels);
|
||||
if (!effort?.supported || levels.length === 0) return null;
|
||||
|
||||
const current = effort.current || "auto";
|
||||
const pick = (level: string) => {
|
||||
@@ -25,32 +29,40 @@ export function EffortSwitcher({
|
||||
return (
|
||||
<div className="modelsw effortsw">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`modelsw__trigger effortsw__trigger ${current !== "auto" ? "effortsw__trigger--explicit" : ""}`}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Gauge size={13} className="modelsw__kind" />
|
||||
<span className="modelsw__label">{t("status.effort", { level: current })}</span>
|
||||
<ChevronsUpDown size={11} />
|
||||
</button>
|
||||
{open && !disabled && (
|
||||
<>
|
||||
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
|
||||
<div className="modelsw__menu effortsw__menu" role="listbox">
|
||||
{effort.levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
role="option"
|
||||
aria-selected={level === current}
|
||||
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
|
||||
onClick={() => pick(level)}
|
||||
>
|
||||
<span className="modelsw__model">{level}</span>
|
||||
{level === current && <Check size={13} className="modelsw__check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<AnchoredPopover
|
||||
open={open && !disabled}
|
||||
anchorRef={triggerRef}
|
||||
onClose={() => setOpen(false)}
|
||||
className="modelsw__menu modelsw__menu--portal effortsw__menu"
|
||||
align="end"
|
||||
>
|
||||
<div role="listbox">
|
||||
{levels.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={level === current}
|
||||
className={`modelsw__item ${level === current ? "modelsw__item--current" : ""}`}
|
||||
onClick={() => pick(level)}
|
||||
>
|
||||
<span className="modelsw__model">{level}</span>
|
||||
{level === current && <Check size={13} className="modelsw__check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Pencil, Search, Trash2, Check, X } from "lucide-react";
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
import { Archive, Pencil, Search, Trash2, RotateCcw } from "lucide-react";
|
||||
import { t, useT } from "../lib/i18n";
|
||||
import { sessionActivityTime } from "../lib/session";
|
||||
import type { HistoryMessage, SessionMeta } from "../lib/types";
|
||||
@@ -7,32 +8,47 @@ import type { Item } from "../lib/useController";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { Transcript } from "./Transcript";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
|
||||
// HistoryPanel lists saved sessions newest-first. Idle clicks resume a session;
|
||||
// running clicks load a read-only preview so the active stream keeps writing to
|
||||
// the current controller/session.
|
||||
export function HistoryPanel({
|
||||
kind = "history",
|
||||
sessions,
|
||||
running,
|
||||
onResume,
|
||||
onPreview,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRestore,
|
||||
onPurge,
|
||||
onPurgeAll,
|
||||
onClose,
|
||||
}: {
|
||||
kind?: "history" | "trash";
|
||||
sessions: SessionMeta[];
|
||||
running: boolean;
|
||||
onResume: (path: string) => void;
|
||||
onResume: (session: SessionMeta) => void;
|
||||
onPreview: (path: string) => Promise<HistoryMessage[]>;
|
||||
onDelete: (path: string) => void;
|
||||
onRename: (path: string, title: string) => void;
|
||||
onRestore?: (path: string) => void;
|
||||
onPurge?: (path: string) => void;
|
||||
onPurgeAll?: (paths: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tr = useT();
|
||||
const isTrash = kind === "trash";
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [menuSession, setMenuSession] = useState<SessionMeta | null>(null);
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
|
||||
>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
path: string;
|
||||
title: string;
|
||||
@@ -44,7 +60,6 @@ export function HistoryPanel({
|
||||
|
||||
const startRename = (s: SessionMeta) => {
|
||||
if (running) return;
|
||||
setConfirming(null);
|
||||
setEditing(s.path);
|
||||
setDraft(s.title || s.preview || "");
|
||||
};
|
||||
@@ -57,11 +72,10 @@ export function HistoryPanel({
|
||||
async (s: SessionMeta) => {
|
||||
const seq = ++previewSeq.current;
|
||||
setEditing(null);
|
||||
setConfirming(null);
|
||||
setPreview({
|
||||
path: s.path,
|
||||
title: sessionDisplayTitle(s, tr("history.emptySession")),
|
||||
meta: sessionMetaLine(s, tr),
|
||||
meta: sessionMetaLine(s, tr, isTrash),
|
||||
messages: [],
|
||||
loading: true,
|
||||
});
|
||||
@@ -70,7 +84,7 @@ export function HistoryPanel({
|
||||
setPreview((cur) => (cur?.path === s.path ? { ...cur, messages, loading: false } : cur));
|
||||
}
|
||||
},
|
||||
[onPreview, tr],
|
||||
[isTrash, onPreview, tr],
|
||||
);
|
||||
|
||||
const filteredSessions = useMemo(() => {
|
||||
@@ -85,7 +99,7 @@ export function HistoryPanel({
|
||||
// (Today / Yesterday / a date) while preserving that order.
|
||||
const groups: { label: string; items: SessionMeta[] }[] = [];
|
||||
for (const s of filteredSessions) {
|
||||
const label = dayLabel(sessionActivityTime(s));
|
||||
const label = dayLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.label === label) last.items.push(s);
|
||||
else groups.push({ label, items: [s] });
|
||||
@@ -96,10 +110,16 @@ export function HistoryPanel({
|
||||
if (!filteredSessions.some((s) => s.path === preview.path)) setPreview(null);
|
||||
}, [filteredSessions, preview]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget(null);
|
||||
}, [isTrash]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setEditing(null);
|
||||
setConfirming(null);
|
||||
if (preview || filteredSessions.length === 0) return;
|
||||
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
|
||||
void loadPreview(first);
|
||||
@@ -107,23 +127,173 @@ export function HistoryPanel({
|
||||
|
||||
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
|
||||
const showPreview = preview !== null;
|
||||
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenuConfirmTarget(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuSession(s);
|
||||
setMenuPoint(contextMenuPointFromEvent(event));
|
||||
};
|
||||
const openTrashBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isTrash || sessions.length === 0) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest(".hist-item,.history-search,.history-preview,button,input,textarea,select")) return;
|
||||
event.preventDefault();
|
||||
setMenuConfirmTarget(null);
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(contextMenuPointFromEvent(event));
|
||||
};
|
||||
const openTrashClearMenu = (event: ReactMouseEvent<HTMLElement>) => {
|
||||
if (!isTrash || sessions.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clear" });
|
||||
setBlankMenuPoint({ left: rect.left, top: rect.bottom + 6 });
|
||||
};
|
||||
const closeHistoryMenus = () => {
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget(null);
|
||||
};
|
||||
const deleteHistorySession = (s: SessionMeta) => {
|
||||
closeHistoryMenus();
|
||||
onDelete(s.path);
|
||||
};
|
||||
const purgeTrashSession = (s: SessionMeta) => {
|
||||
closeHistoryMenus();
|
||||
onPurge?.(s.path);
|
||||
};
|
||||
const clearTrash = () => {
|
||||
const paths = sessions.map((s) => s.path);
|
||||
closeHistoryMenus();
|
||||
onPurgeAll?.(paths);
|
||||
};
|
||||
const sessionMenuItems: ContextMenuItem[] = menuSession
|
||||
? isTrash
|
||||
? [
|
||||
{
|
||||
key: "restore",
|
||||
icon: <RotateCcw size={13} />,
|
||||
label: tr("history.restoreSession"),
|
||||
onSelect: () => {
|
||||
onRestore?.(menuSession.path);
|
||||
closeHistoryMenus();
|
||||
},
|
||||
},
|
||||
{ type: "separator", key: "trash-session-separator" },
|
||||
{
|
||||
key: "purge",
|
||||
icon: <Trash2 size={13} />,
|
||||
label:
|
||||
menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path
|
||||
? tr("history.confirmPurge")
|
||||
: tr("history.purgeSession"),
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
if (menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === menuSession.path) {
|
||||
purgeTrashSession(menuSession);
|
||||
} else {
|
||||
setMenuConfirmTarget({ kind: "purge", path: menuSession.path });
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "rename",
|
||||
icon: <Pencil size={13} />,
|
||||
label: tr("history.rename"),
|
||||
disabled: running,
|
||||
onSelect: () => {
|
||||
const target = menuSession;
|
||||
closeHistoryMenus();
|
||||
startRename(target);
|
||||
},
|
||||
},
|
||||
...(menuSession.current
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "delete",
|
||||
icon: <Archive size={13} />,
|
||||
label:
|
||||
menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path
|
||||
? tr("history.confirmMoveToTrash")
|
||||
: tr("history.moveToTrash"),
|
||||
disabled: running,
|
||||
danger: menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path,
|
||||
onSelect: () => {
|
||||
if (menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === menuSession.path) {
|
||||
deleteHistorySession(menuSession);
|
||||
} else {
|
||||
setMenuConfirmTarget({ kind: "delete", path: menuSession.path });
|
||||
}
|
||||
},
|
||||
} as ContextMenuItem,
|
||||
]),
|
||||
]
|
||||
: [];
|
||||
const trashBlankMenuItems: ContextMenuItem[] =
|
||||
menuConfirmTarget?.kind === "clear"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ResizableDrawer onClose={onClose} wide={showPreview || running}>
|
||||
<header className="drawer__head">
|
||||
<div>
|
||||
<div className="drawer__title">{tr("history.title")}</div>
|
||||
{running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>}
|
||||
<div className="drawer__title">{tr(isTrash ? "history.trashTitle" : "history.title")}</div>
|
||||
{isTrash ? (
|
||||
<div className="drawer__summary">{tr("history.trashHint")}</div>
|
||||
) : (
|
||||
running && <div className="drawer__summary">{tr("history.readOnlyHint")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="drawer__actions">
|
||||
{isTrash && sessions.length > 0 && (
|
||||
<button
|
||||
className="chip history-clear"
|
||||
type="button"
|
||||
onClick={openTrashClearMenu}
|
||||
>
|
||||
{tr("history.clearTrash")}
|
||||
</button>
|
||||
)}
|
||||
<Tooltip label={tr("common.close")}>
|
||||
<button className="chip" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Tooltip label={tr("common.close")}>
|
||||
<button className="chip" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<div className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}>
|
||||
<div className="history-list">
|
||||
<div
|
||||
className={`drawer__body history-drawer${showPreview ? " history-drawer--preview" : ""}`}
|
||||
onContextMenu={openTrashBlankMenu}
|
||||
>
|
||||
<div className={`history-list${isTrash ? " history-list--trash" : ""}`}>
|
||||
{sessions.length > 0 && (
|
||||
<label className="mem-search history-search">
|
||||
<Search size={13} />
|
||||
@@ -131,7 +301,10 @@ export function HistoryPanel({
|
||||
</label>
|
||||
)}
|
||||
{sessions.length === 0 ? (
|
||||
<div className="mem-empty">{tr("history.empty")}</div>
|
||||
<div className={`mem-empty${isTrash ? " mem-empty--trash" : ""}`}>
|
||||
{isTrash && <Trash2 size={22} />}
|
||||
<span>{tr(isTrash ? "history.trashEmpty" : "history.empty")}</span>
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="mem-empty">{tr("history.noResults")}</div>
|
||||
) : (
|
||||
@@ -144,6 +317,7 @@ export function HistoryPanel({
|
||||
<div
|
||||
className={`hist-item${s.current ? " hist-item--current" : ""}${selected ? " hist-item--selected" : ""}`}
|
||||
key={s.path}
|
||||
onContextMenu={(event) => openSessionMenu(event, s)}
|
||||
>
|
||||
{editing === s.path ? (
|
||||
<input
|
||||
@@ -162,17 +336,24 @@ export function HistoryPanel({
|
||||
<button
|
||||
className="hist-item__main"
|
||||
onClick={() => {
|
||||
if (running) void loadPreview(s);
|
||||
else onResume(s.path);
|
||||
if (isTrash || running) void loadPreview(s);
|
||||
else onResume(s);
|
||||
}}
|
||||
>
|
||||
<div className="hist-item__preview">{sessionDisplayTitle(s, tr("history.emptySession"))}</div>
|
||||
<div className="hist-item__meta">
|
||||
{s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
|
||||
{!isTrash && s.current && <span className="hist-item__badge">{tr("history.current")}</span>}
|
||||
{!isTrash && !s.current && s.open && <span className="hist-item__badge">{tr("history.open")}</span>}
|
||||
<span>{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
|
||||
<span>·</span>
|
||||
<span>{timeLabel(sessionActivityTime(s))}</span>
|
||||
{running && (
|
||||
<span>{timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s))}</span>
|
||||
{isTrash && s.deletedAt && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{tr("history.deleted")}</span>
|
||||
</>
|
||||
)}
|
||||
{!isTrash && running && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{tr("history.preview")}</span>
|
||||
@@ -182,55 +363,6 @@ export function HistoryPanel({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{editing !== s.path && (
|
||||
<div className="hist-item__actions">
|
||||
{confirming === s.path ? (
|
||||
<>
|
||||
<Tooltip label={tr("history.confirmDelete")}>
|
||||
<button
|
||||
className="hist-act hist-act--danger"
|
||||
disabled={running}
|
||||
onClick={() => {
|
||||
if (running) return;
|
||||
onDelete(s.path);
|
||||
setConfirming(null);
|
||||
}}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={tr("common.cancel")}>
|
||||
<button className="hist-act" onClick={() => setConfirming(null)}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip label={tr("history.rename")}>
|
||||
<button
|
||||
className="hist-act"
|
||||
disabled={running}
|
||||
onClick={() => startRename(s)}
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{!s.current && (
|
||||
<Tooltip label={tr("common.delete")}>
|
||||
<button
|
||||
className="hist-act"
|
||||
disabled={running}
|
||||
onClick={() => setConfirming(s.path)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -251,11 +383,27 @@ export function HistoryPanel({
|
||||
) : previewItems.length === 0 ? (
|
||||
<div className="mem-empty">{tr("history.previewEmpty")}</div>
|
||||
) : (
|
||||
<Transcript items={previewItems} onPrompt={() => {}} />
|
||||
<Transcript items={previewItems} onPrompt={() => {}} questionNavigator={false} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<ContextMenu
|
||||
open={Boolean(menuSession)}
|
||||
point={menuPoint}
|
||||
items={sessionMenuItems}
|
||||
minWidth={220}
|
||||
ariaLabel={isTrash ? tr("history.trashSessionActions") : tr("history.historySessionActions")}
|
||||
onClose={closeHistoryMenus}
|
||||
/>
|
||||
<ContextMenu
|
||||
open={Boolean(blankMenuPoint)}
|
||||
point={blankMenuPoint}
|
||||
items={trashBlankMenuItems}
|
||||
minWidth={220}
|
||||
ariaLabel={tr("history.trashActions")}
|
||||
onClose={closeHistoryMenus}
|
||||
/>
|
||||
</div>
|
||||
</ResizableDrawer>
|
||||
);
|
||||
@@ -280,8 +428,10 @@ function sessionDisplayTitle(s: SessionMeta, fallback: string): string {
|
||||
return s.title || s.preview || fallback;
|
||||
}
|
||||
|
||||
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>): string {
|
||||
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${timeLabel(sessionActivityTime(s))}`;
|
||||
function sessionMetaLine(s: SessionMeta, tr: ReturnType<typeof useT>, isTrash = false): string {
|
||||
const time = timeLabel(isTrash ? s.deletedAt || sessionActivityTime(s) : sessionActivityTime(s));
|
||||
const suffix = isTrash && s.deletedAt ? ` · ${tr("history.deleted")}` : "";
|
||||
return `${tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })} · ${time}${suffix}`;
|
||||
}
|
||||
|
||||
function previewMessagesToItems(messages: HistoryMessage[]): Item[] {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Row-level destructive actions should confirm in place instead of opening a
|
||||
// global modal. First click arms the action, second click confirms it, and the
|
||||
// adjacent Cancel button or any disabled state returns the button to normal.
|
||||
export function InlineConfirmButton({
|
||||
label,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
disabled = false,
|
||||
danger = false,
|
||||
onConfirm,
|
||||
}: {
|
||||
label: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
}) {
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) setArmed(false);
|
||||
}, [disabled]);
|
||||
|
||||
const run = async () => {
|
||||
if (!armed) {
|
||||
setArmed(true);
|
||||
return;
|
||||
}
|
||||
setArmed(false);
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="inline-confirm">
|
||||
<button
|
||||
className={`btn btn--small${armed && danger ? " btn--danger" : ""}`}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
onClick={run}
|
||||
>
|
||||
{armed ? confirmLabel : label}
|
||||
</button>
|
||||
{armed && (
|
||||
<button className="btn btn--small" disabled={disabled} type="button" onClick={() => setArmed(false)}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useDeferredValue } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -58,10 +59,16 @@ function normalizeMath(s: string): string {
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
// useDeferredValue lets React prioritise the plain-text streaming frame over
|
||||
// the expensive markdown parse+render. If a new text delta arrives while the
|
||||
// markdown tree is still diffing, React can abort the in-progress render and
|
||||
// start fresh with the latest text — keeping the UI responsive during the
|
||||
// final markdown pass on long responses.
|
||||
const deferred = useDeferredValue(text);
|
||||
return (
|
||||
<div className="md">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
|
||||
{normalizeMath(text)}
|
||||
{normalizeMath(deferred)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -83,7 +83,7 @@ export function MemoryPanel({
|
||||
const scrollToFact = (name: string) => {
|
||||
const el = factRefs.current[name];
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
el.scrollIntoView({ block: "center", behavior: "auto" });
|
||||
setHighlight(name);
|
||||
window.setTimeout(() => setHighlight((h) => (h === name ? null : h)), 1200);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,158 @@
|
||||
import { memo, useState } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Item } from "../lib/useController";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
|
||||
export function UserMessage({
|
||||
text,
|
||||
turn,
|
||||
anchorId,
|
||||
open,
|
||||
onToggle,
|
||||
onRewind,
|
||||
checkpoint,
|
||||
actionPending = false,
|
||||
rewindDisabled = false,
|
||||
}: {
|
||||
text: string;
|
||||
turn?: number;
|
||||
anchorId?: string;
|
||||
open?: boolean; // whether this message's rewind menu is the open one (lifted to Transcript)
|
||||
onToggle?: () => void;
|
||||
onRewind?: (turn: number, scope: string) => void;
|
||||
checkpoint?: CheckpointMeta;
|
||||
actionPending?: boolean;
|
||||
rewindDisabled?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [confirmScope, setConfirmScope] = useState<string | null>(null);
|
||||
const canRewind = onRewind != null && turn != null;
|
||||
const rewind = (scope: string) => onRewind?.(turn as number, scope);
|
||||
const actionDisabledReason = (scope: string): string => {
|
||||
if (rewindDisabled || actionPending) return t("rewind.disabledRunning");
|
||||
if (!checkpoint) return t("rewind.disabledNoCheckpoint");
|
||||
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
|
||||
return t("rewind.disabledNoBoundary");
|
||||
}
|
||||
if (scope === "summ-upto") {
|
||||
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
|
||||
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
|
||||
}
|
||||
if (scope === "code" && !checkpoint.canCode) return t("rewind.disabledNoCode");
|
||||
if (scope === "both") {
|
||||
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
|
||||
if (!checkpoint.canCode) return t("rewind.disabledNoCode");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const actionLabel = (scope: string): string => {
|
||||
if (confirmScope !== scope) {
|
||||
switch (scope) {
|
||||
case "fork":
|
||||
return t("rewind.fork");
|
||||
case "summ-from":
|
||||
return t("rewind.summFrom");
|
||||
case "summ-upto":
|
||||
return t("rewind.summUpto");
|
||||
case "conversation":
|
||||
return t("rewind.conversation");
|
||||
case "code":
|
||||
return t("rewind.code");
|
||||
default:
|
||||
return t("rewind.both");
|
||||
}
|
||||
}
|
||||
switch (scope) {
|
||||
case "fork":
|
||||
return t("rewind.confirmFork");
|
||||
case "summ-from":
|
||||
return t("rewind.confirmSummFrom");
|
||||
case "summ-upto":
|
||||
return t("rewind.confirmSummUpto");
|
||||
case "conversation":
|
||||
return t("rewind.confirmConversation");
|
||||
case "code":
|
||||
return t("rewind.confirmCode");
|
||||
default:
|
||||
return t("rewind.confirmBoth");
|
||||
}
|
||||
};
|
||||
const actionMeta = (scope: string): string => {
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
return t("rewind.filesChanged", { count: checkpoint.files.length });
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const runAction = (scope: string) => {
|
||||
setConfirmScope(null);
|
||||
onRewind?.(turn as number, scope);
|
||||
};
|
||||
const selectRewind = (scope: string) => {
|
||||
if (actionDisabledReason(scope)) return;
|
||||
if (confirmScope !== scope) {
|
||||
setConfirmScope(scope);
|
||||
return;
|
||||
}
|
||||
runAction(scope);
|
||||
};
|
||||
const renderAction = (scope: string, danger = false) => {
|
||||
const disabledReason = actionDisabledReason(scope);
|
||||
const meta = actionMeta(scope);
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
"rewind__menu-item",
|
||||
danger ? "rewind__menu-danger" : "",
|
||||
confirmScope === scope ? "rewind__menu-confirm" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
disabled={Boolean(disabledReason)}
|
||||
title={disabledReason || undefined}
|
||||
onClick={() => selectRewind(scope)}
|
||||
>
|
||||
<span>{actionLabel(scope)}</span>
|
||||
{meta && <span className="rewind__menu-meta">{meta}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const displayText = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]");
|
||||
return (
|
||||
<div className="msg msg--user">
|
||||
<div className="msg msg--user" id={anchorId} data-question-anchor={anchorId} data-turn={turn}>
|
||||
<span className="msg__caret">›</span>
|
||||
<div className="msg__text">{displayText}</div>
|
||||
{canRewind && (
|
||||
<div className="rewind">
|
||||
<div className={`rewind${open ? " rewind--open" : ""}`}>
|
||||
<Tooltip label={t("rewind.label")}>
|
||||
<button className="rewind__btn" onClick={onToggle}>
|
||||
⟲
|
||||
<button
|
||||
className="rewind__btn"
|
||||
type="button"
|
||||
aria-label={t("rewind.label")}
|
||||
aria-expanded={Boolean(open)}
|
||||
onClick={() => {
|
||||
setConfirmScope(null);
|
||||
onToggle?.();
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<div className="rewind__menu">
|
||||
<button onClick={() => rewind("both")}>{t("rewind.both")}</button>
|
||||
<button onClick={() => rewind("conversation")}>{t("rewind.conversation")}</button>
|
||||
<button onClick={() => rewind("code")}>{t("rewind.code")}</button>
|
||||
<button onClick={() => rewind("fork")}>{t("rewind.fork")}</button>
|
||||
<button onClick={() => rewind("summ-from")}>{t("rewind.summFrom")}</button>
|
||||
<button onClick={() => rewind("summ-upto")}>{t("rewind.summUpto")}</button>
|
||||
{rewindDisabled && <div className="rewind__menu-hint">{t("rewind.disabledRunning")}</div>}
|
||||
{!rewindDisabled && !checkpoint && <div className="rewind__menu-hint">{t("rewind.disabledNoCheckpoint")}</div>}
|
||||
{renderAction("fork")}
|
||||
<div className="rewind__menu-separator" />
|
||||
{renderAction("summ-from")}
|
||||
{renderAction("summ-upto")}
|
||||
<div className="rewind__menu-separator" />
|
||||
{renderAction("conversation")}
|
||||
{renderAction("code")}
|
||||
{renderAction("both", true)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Brain, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { ModelInfo } from "../lib/types";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// ModelSwitcher is the bottom-of-window model picker: the status line's model
|
||||
// label becomes a button that opens a popover (upward) listing configured
|
||||
// providers. Selecting one switches the active model; the conversation is carried
|
||||
// over by the backend, so the chat continues. Mirrors the "switch model, keep the
|
||||
// session" behavior of comparable coding agents.
|
||||
export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name: string) => void }) {
|
||||
// ModelSwitcher opens an upward popover listing configured providers. Selecting
|
||||
// one switches the active model while the current conversation continues.
|
||||
export function ModelSwitcher({ label, tabId, onPick }: { label: string; tabId?: string; onPick: (name: string) => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) app.Models().then(setModels).catch(() => {});
|
||||
}, [open]);
|
||||
if (open) {
|
||||
(tabId ? app.ModelsForTab(tabId) : app.Models()).then((next) => setModels(asArray(next))).catch(() => {});
|
||||
}
|
||||
}, [open, tabId]);
|
||||
|
||||
const pick = (name: string) => {
|
||||
setOpen(false);
|
||||
@@ -25,30 +27,40 @@ export function ModelSwitcher({ label, onPick }: { label: string; onPick: (name:
|
||||
|
||||
return (
|
||||
<div className="modelsw">
|
||||
<button className="modelsw__trigger" onClick={() => setOpen((v) => !v)}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="modelsw__trigger"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<Brain size={13} className="modelsw__kind" />
|
||||
<span className="modelsw__label">{label}</span>
|
||||
<ChevronsUpDown size={11} />
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="modelsw__backdrop" onClick={() => setOpen(false)} />
|
||||
<div className="modelsw__menu" role="listbox">
|
||||
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.ref}
|
||||
role="option"
|
||||
aria-selected={m.current}
|
||||
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
|
||||
onClick={() => pick(m.ref)}
|
||||
>
|
||||
<span className="modelsw__model">{m.model}</span>
|
||||
{m.current && <Check size={13} className="modelsw__check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<AnchoredPopover
|
||||
open={open}
|
||||
anchorRef={triggerRef}
|
||||
onClose={() => setOpen(false)}
|
||||
className="modelsw__menu modelsw__menu--portal"
|
||||
>
|
||||
<div role="listbox">
|
||||
{models.length === 0 && <div className="modelsw__empty">{t("status.noModels")}</div>}
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m.ref}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={m.current}
|
||||
className={`modelsw__item ${m.current ? "modelsw__item--current" : ""}`}
|
||||
onClick={() => pick(m.ref)}
|
||||
>
|
||||
<span className="modelsw__model">{m.model}</span>
|
||||
{m.current && <Check size={13} className="modelsw__check" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,811 @@
|
||||
// ProjectTree is the sidebar replacement for the flat recent-sessions list.
|
||||
// It shows a tree of projects (each with expandable topics) plus a Global
|
||||
// section. Clicking a topic opens its tab; "+" next to a project creates a
|
||||
// new topic.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
import { Archive, ChevronRight, ChevronDown, Pencil, Plus, Folder, FolderGit2, FolderPlus, Search, BriefcaseBusiness, Copy, FolderOpen, XCircle, History, Check } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { getLocale, useT, type Translator } from "../lib/i18n";
|
||||
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
interface ProjectTreeProps {
|
||||
activeScope?: string;
|
||||
activeWorkspaceRoot?: string;
|
||||
activeTopicId?: string;
|
||||
currentWorkspaceName?: string;
|
||||
onOpenTopic: (scope: string, workspaceRoot: string, topicId: string) => Promise<void> | void;
|
||||
onOpenProjectHistory: (scope: "global" | "project", workspaceRoot: string) => Promise<void> | void;
|
||||
onAddProject: () => Promise<void>;
|
||||
onUseCurrentProject?: () => Promise<void>;
|
||||
onRenameTopic?: (topicId: string, title: string) => Promise<void> | void;
|
||||
onTopicsChanged?: () => Promise<void> | void;
|
||||
refreshSignal?: number;
|
||||
}
|
||||
|
||||
function projectNodeKey(node: ProjectNode, depth: number): string {
|
||||
return node.key || `${node.kind}-${node.root ?? ""}-${node.topicId ?? ""}-${depth}`;
|
||||
}
|
||||
|
||||
function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceRoot?: string, activeTopicId?: string): boolean {
|
||||
if (node.kind !== "topic" && node.kind !== "global_topic") return false;
|
||||
const scope = node.kind === "global_topic" ? "global" : "project";
|
||||
return (
|
||||
activeTopicId === node.topicId &&
|
||||
activeScope === scope &&
|
||||
(scope === "global" || activeWorkspaceRoot === node.root)
|
||||
);
|
||||
}
|
||||
|
||||
function topicMetaLine(node: ProjectNode, t: Translator): string {
|
||||
const turns = node.turns ?? 0;
|
||||
if (turns <= 0) return "";
|
||||
const last = node.lastActivityAt ? ` · ${topicActivityLabel(node.lastActivityAt)}` : "";
|
||||
return `${t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns })}${last}`;
|
||||
}
|
||||
|
||||
function topicActivityLabel(ms: number): string {
|
||||
if (ms <= 0) return "";
|
||||
const delta = Date.now() - ms;
|
||||
const locale = getLocale();
|
||||
const rtf = new Intl.RelativeTimeFormat(locale === "zh" ? "zh-CN" : "en", { numeric: "auto" });
|
||||
const minute = 60_000;
|
||||
const hour = 60 * minute;
|
||||
const day = 24 * hour;
|
||||
if (delta < minute) return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
if (delta < hour) return rtf.format(-Math.max(1, Math.round(delta / minute)), "minute");
|
||||
if (delta < day) return rtf.format(-Math.round(delta / hour), "hour");
|
||||
if (delta < 7 * day) return rtf.format(-Math.round(delta / day), "day");
|
||||
return new Date(ms).toLocaleDateString();
|
||||
}
|
||||
|
||||
type ProjectDropPosition = "before" | "after";
|
||||
|
||||
function projectRoots(nodes: ProjectNode[]): string[] {
|
||||
return nodes
|
||||
.filter((node) => node.kind === "project" && Boolean(node.root))
|
||||
.map((node) => node.root!);
|
||||
}
|
||||
|
||||
function reorderedProjectRoots(nodes: ProjectNode[], draggedRoot: string, targetRoot: string, position: ProjectDropPosition): string[] {
|
||||
const roots = projectRoots(nodes);
|
||||
if (draggedRoot === targetRoot || !roots.includes(draggedRoot) || !roots.includes(targetRoot)) return roots;
|
||||
const next = roots.filter((root) => root !== draggedRoot);
|
||||
const targetIndex = next.indexOf(targetRoot);
|
||||
if (targetIndex < 0) return roots;
|
||||
next.splice(position === "before" ? targetIndex : targetIndex + 1, 0, draggedRoot);
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyProjectOrder(nodes: ProjectNode[], roots: string[]): ProjectNode[] {
|
||||
const byRoot = new Map(nodes.filter((node) => node.kind === "project" && node.root).map((node) => [node.root!, node]));
|
||||
const orderedProjects = roots.map((root) => byRoot.get(root)).filter((node): node is ProjectNode => Boolean(node));
|
||||
const nonProjects = nodes.filter((node) => node.kind !== "project");
|
||||
return [...nonProjects, ...orderedProjects];
|
||||
}
|
||||
|
||||
function projectAccentStyle(color?: string): CSSProperties | undefined {
|
||||
const value = projectColorValue(color);
|
||||
if (!value) return undefined;
|
||||
return { "--project-accent": value } as CSSProperties;
|
||||
}
|
||||
|
||||
function colorMenuLabel(label: string, color?: string, active = false) {
|
||||
const value = projectColorValue(color);
|
||||
return (
|
||||
<span className="project-tree__color-option">
|
||||
<span
|
||||
className="project-tree__color-swatch"
|
||||
style={value ? ({ "--project-accent": value } as CSSProperties) : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{active && <Check className="project-tree__color-check" size={12} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function revealLabelKey(platform: string): "projectTree.revealInFinder" | "projectTree.revealInExplorer" | "projectTree.revealInFileManager" {
|
||||
if (platform === "darwin") return "projectTree.revealInFinder";
|
||||
if (platform === "windows") return "projectTree.revealInExplorer";
|
||||
return "projectTree.revealInFileManager";
|
||||
}
|
||||
|
||||
function projectColorLabel(t: Translator, color?: string): string {
|
||||
switch (color) {
|
||||
case "red": return t("projectTree.colorRed");
|
||||
case "orange": return t("projectTree.colorOrange");
|
||||
case "amber": return t("projectTree.colorAmber");
|
||||
case "green": return t("projectTree.colorGreen");
|
||||
case "teal": return t("projectTree.colorTeal");
|
||||
case "blue": return t("projectTree.colorBlue");
|
||||
case "purple": return t("projectTree.colorPurple");
|
||||
case "pink": return t("projectTree.colorPink");
|
||||
default: return t("projectTree.colorDefault");
|
||||
}
|
||||
}
|
||||
|
||||
export function ProjectTree({
|
||||
activeScope,
|
||||
activeWorkspaceRoot,
|
||||
activeTopicId,
|
||||
currentWorkspaceName,
|
||||
onOpenTopic,
|
||||
onOpenProjectHistory,
|
||||
onAddProject,
|
||||
onUseCurrentProject,
|
||||
onRenameTopic,
|
||||
onTopicsChanged,
|
||||
refreshSignal,
|
||||
}: ProjectTreeProps) {
|
||||
const t = useT();
|
||||
const [tree, setTree] = useState<ProjectNode[]>([]);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [manuallyCollapsed, setManuallyCollapsed] = useState<Set<string>>(new Set());
|
||||
const [creatingProject, setCreatingProject] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editingTopic, setEditingTopic] = useState<string | null>(null);
|
||||
const [topicDraft, setTopicDraft] = useState("");
|
||||
const [menuTopic, setMenuTopic] = useState<string | null>(null);
|
||||
const [menuProject, setMenuProject] = useState<{ key: string; root: string; path: string; scope: "global" | "project"; label: string } | null>(null);
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [editingProject, setEditingProject] = useState<{ key: string; root: string } | null>(null);
|
||||
const [projectDraft, setProjectDraft] = useState("");
|
||||
const [addingProject, setAddingProject] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<{ topicId: string; action: "trash" } | null>(null);
|
||||
const [confirmRemoveProject, setConfirmRemoveProject] = useState<string | null>(null);
|
||||
const [dragProjectRoot, setDragProjectRoot] = useState<string | null>(null);
|
||||
const [dropProject, setDropProject] = useState<{ root: string; position: ProjectDropPosition } | null>(null);
|
||||
const [platform, setPlatform] = useState("");
|
||||
const creatingRef = useRef(false);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setMenuTopic(null);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
setConfirmRemoveProject(null);
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const nodes = await app.ListProjectTree();
|
||||
const list = asArray(nodes);
|
||||
setTree(list);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const node of list) {
|
||||
if (node?.key && !manuallyCollapsed.has(node.key)) next.add(node.key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
}, [manuallyCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void app.Platform().then((value) => {
|
||||
if (!cancelled) setPlatform(value);
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleExpand = (key: string) => {
|
||||
const willCollapse = expanded.has(key);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
setManuallyCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (willCollapse) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddProject = async () => {
|
||||
if (addingProject) return;
|
||||
setAddingProject(true);
|
||||
try {
|
||||
await onAddProject();
|
||||
await refresh();
|
||||
} finally {
|
||||
setAddingProject(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTopic = async (scope: string, workspaceRoot: string, key: string) => {
|
||||
if (creatingRef.current) return;
|
||||
creatingRef.current = true;
|
||||
setCreatingProject(key);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
setManuallyCollapsed((prev) => {
|
||||
if (!prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
const topic = await app.CreateTopic(scope, workspaceRoot, "");
|
||||
await refresh();
|
||||
await onTopicsChanged?.();
|
||||
await onOpenTopic(scope, workspaceRoot, topic.id);
|
||||
} catch {
|
||||
/* ignore */
|
||||
} finally {
|
||||
creatingRef.current = false;
|
||||
setCreatingProject(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRenameTopic = (node: ProjectNode, label: string) => {
|
||||
setMenuTopic(null);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
setEditingTopic(node.topicId ?? null);
|
||||
setTopicDraft(label);
|
||||
};
|
||||
|
||||
const startRenameProject = (key: string, root: string, label: string) => {
|
||||
setMenuProject(null);
|
||||
setMenuTopic(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmRemoveProject(null);
|
||||
setEditingProject({ key, root });
|
||||
setProjectDraft(label);
|
||||
};
|
||||
|
||||
const commitRenameTopic = async (topicId: string) => {
|
||||
const title = topicDraft.trim();
|
||||
setEditingTopic(null);
|
||||
if (!title) return;
|
||||
try {
|
||||
if (onRenameTopic) await onRenameTopic(topicId, title);
|
||||
else await app.RenameTopic(topicId, title);
|
||||
await refresh();
|
||||
if (!onRenameTopic) await onTopicsChanged?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const commitRenameProject = async (root: string) => {
|
||||
const title = projectDraft.trim();
|
||||
setEditingProject(null);
|
||||
if (!title) return;
|
||||
try {
|
||||
await app.RenameProject(root, title);
|
||||
await refresh();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const trashTopic = async (topicId: string) => {
|
||||
try {
|
||||
await app.TrashTopic(topicId);
|
||||
setMenuTopic(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmAction(null);
|
||||
await refresh();
|
||||
await onTopicsChanged?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const copyProjectPath = async (path: string) => {
|
||||
if (!path) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const removeProject = async (path: string) => {
|
||||
if (!path) return;
|
||||
try {
|
||||
await app.RemoveWorkspace(path);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
setConfirmRemoveProject(null);
|
||||
await refresh();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const setProjectColor = async (path: string, color: string) => {
|
||||
try {
|
||||
await app.SetProjectColor(path, color);
|
||||
setMenuProject(null);
|
||||
setMenuPoint(null);
|
||||
await refresh();
|
||||
await onTopicsChanged?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const visibleTree = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return tree;
|
||||
const matches = (node: ProjectNode) =>
|
||||
[node.label, node.root, node.topicId].some((value) => (value ?? "").toLowerCase().includes(q));
|
||||
const filterNode = (node: ProjectNode): ProjectNode | null => {
|
||||
const children = asArray(node.children)
|
||||
.map(filterNode)
|
||||
.filter((child): child is ProjectNode => child !== null);
|
||||
if (matches(node) || children.length > 0) return { ...node, children };
|
||||
return null;
|
||||
};
|
||||
return tree
|
||||
.map(filterNode)
|
||||
.filter((node): node is ProjectNode => node !== null);
|
||||
}, [query, tree]);
|
||||
|
||||
const projectDragEnabled = query.trim() === "";
|
||||
|
||||
const commitProjectReorder = useCallback(async (draggedRoot: string, targetRoot: string, position: ProjectDropPosition) => {
|
||||
const nextRoots = reorderedProjectRoots(tree, draggedRoot, targetRoot, position);
|
||||
const currentRoots = projectRoots(tree);
|
||||
if (nextRoots.join("\n") === currentRoots.join("\n")) return;
|
||||
setTree((current) => applyProjectOrder(current, nextRoots));
|
||||
try {
|
||||
await app.ReorderProjects(nextRoots);
|
||||
await refresh();
|
||||
await onTopicsChanged?.();
|
||||
} catch {
|
||||
await refresh();
|
||||
}
|
||||
}, [onTopicsChanged, refresh, tree]);
|
||||
|
||||
const clearProjectDrag = useCallback(() => {
|
||||
setDragProjectRoot(null);
|
||||
setDropProject(null);
|
||||
}, []);
|
||||
|
||||
const activeAncestorKeys = useMemo(() => {
|
||||
const walk = (nodes: ProjectNode[], ancestors: string[]): string[] | null => {
|
||||
for (const node of nodes) {
|
||||
if (!node) continue;
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId)) return ancestors;
|
||||
const children = asArray(node.children);
|
||||
if (children.length > 0) {
|
||||
const next = walk(children, [...ancestors, projectNodeKey(node, ancestors.length)]);
|
||||
if (next) return next;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(tree, []) ?? [];
|
||||
}, [activeScope, activeTopicId, activeWorkspaceRoot, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeAncestorKeys.length === 0) return;
|
||||
setExpanded((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const key of activeAncestorKeys) {
|
||||
if (manuallyCollapsed.has(key) || next.has(key)) continue;
|
||||
next.add(key);
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [activeAncestorKeys, manuallyCollapsed]);
|
||||
|
||||
const renderNode = (node: ProjectNode | null | undefined, depth: number) => {
|
||||
if (!node) return null;
|
||||
const key = projectNodeKey(node, depth);
|
||||
const children = asArray(node.children);
|
||||
const isExpanded = query.trim() ? true : expanded.has(key);
|
||||
const hasChildren = children.length > 0;
|
||||
|
||||
if (node.kind === "topic" || node.kind === "global_topic") {
|
||||
const scope = node.kind === "global_topic" ? "global" : "project";
|
||||
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId);
|
||||
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
|
||||
const meta = topicMetaLine(node, t);
|
||||
const topicId = node.topicId ?? "";
|
||||
const topicMenuOpen = menuTopic === topicId;
|
||||
const openTopicMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenuProject(null);
|
||||
setConfirmRemoveProject(null);
|
||||
setMenuPoint(contextMenuPointFromEvent(event));
|
||||
setMenuTopic(topicId);
|
||||
setConfirmAction(null);
|
||||
};
|
||||
const topicMenuItems: ContextMenuItem[] = [
|
||||
{
|
||||
key: "rename",
|
||||
icon: <Pencil size={13} />,
|
||||
label: t("projectTree.renameTopic"),
|
||||
onSelect: () => startRenameTopic(node, label),
|
||||
},
|
||||
{
|
||||
key: "trash",
|
||||
icon: <Archive size={13} />,
|
||||
label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
|
||||
else setConfirmAction({ topicId, action: "trash" });
|
||||
},
|
||||
},
|
||||
];
|
||||
if (editingTopic === topicId) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`project-tree__topic project-tree__topic--editing${active ? " project-tree__topic--active" : ""}`}
|
||||
style={{ paddingLeft: 14 + depth * 16 }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
className="project-tree__topic-input"
|
||||
value={topicDraft}
|
||||
onChange={(event) => setTopicDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitRenameTopic(topicId);
|
||||
if (event.key === "Escape") setEditingTopic(null);
|
||||
}}
|
||||
onBlur={() => void commitRenameTopic(topicId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`project-tree__topic${active ? " project-tree__topic--active" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}`}
|
||||
style={projectAccentStyle(node.projectColor)}
|
||||
onContextMenu={openTopicMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="project-tree__topic-main"
|
||||
title={meta ? `${label} · ${meta}` : label}
|
||||
style={{ paddingLeft: 14 + depth * 16 }}
|
||||
onClick={() => onOpenTopic(scope, node.root ?? "", topicId)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
||||
openTopicMenu(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
<ContextMenu
|
||||
open={topicMenuOpen}
|
||||
point={menuPoint}
|
||||
items={topicMenuItems}
|
||||
minWidth={178}
|
||||
ariaLabel={t("projectTree.topicActions")}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const scope = node.kind === "global_folder" ? "global" : "project";
|
||||
const projectRoot = scope === "global" ? "" : node.root ?? "";
|
||||
const projectPath = node.root ?? "";
|
||||
const colorTargetRoot = scope === "global" ? "" : projectPath;
|
||||
const projectLabel = node.label || (scope === "global" ? "Global" : "Untitled");
|
||||
const projectActive = activeScope === scope && (scope === "global" || activeWorkspaceRoot === node.root);
|
||||
const draggableProject = projectDragEnabled && scope === "project" && depth === 0 && Boolean(projectRoot) && editingProject?.key !== key;
|
||||
const projectDropPosition = dropProject?.root === projectRoot ? dropProject.position : null;
|
||||
const handleProjectDragStart = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!draggableProject) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("button,input,textarea,select")) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", projectRoot);
|
||||
setDragProjectRoot(projectRoot);
|
||||
setDropProject(null);
|
||||
};
|
||||
const handleProjectDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!draggableProject || !dragProjectRoot || dragProjectRoot === projectRoot) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const position: ProjectDropPosition = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
|
||||
setDropProject((current) => {
|
||||
if (current?.root === projectRoot && current.position === position) return current;
|
||||
return { root: projectRoot, position };
|
||||
});
|
||||
};
|
||||
const handleProjectDrop = (event: ReactDragEvent<HTMLDivElement>) => {
|
||||
if (!draggableProject) return;
|
||||
const draggedRoot = dragProjectRoot || event.dataTransfer.getData("text/plain");
|
||||
const position = dropProject?.root === projectRoot ? dropProject.position : "after";
|
||||
event.preventDefault();
|
||||
clearProjectDrag();
|
||||
if (draggedRoot && draggedRoot !== projectRoot) void commitProjectReorder(draggedRoot, projectRoot, position);
|
||||
};
|
||||
const openProjectMenu = (event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenuTopic(null);
|
||||
setConfirmAction(null);
|
||||
setMenuPoint(contextMenuPointFromEvent(event));
|
||||
setMenuProject({ key, root: projectRoot, path: projectPath, scope, label: projectLabel });
|
||||
setConfirmRemoveProject(null);
|
||||
};
|
||||
const projectMenuItems: ContextMenuItem[] = [
|
||||
{
|
||||
key: "new-session",
|
||||
icon: <Plus size={13} />,
|
||||
label: t("projectTree.newTopic"),
|
||||
onSelect: () => {
|
||||
void handleCreateTopic(scope, projectRoot, key);
|
||||
},
|
||||
},
|
||||
...(scope === "project"
|
||||
? [
|
||||
{
|
||||
key: "project-history",
|
||||
icon: <History size={13} />,
|
||||
label: t("projectTree.projectHistory"),
|
||||
onSelect: () => {
|
||||
closeMenu();
|
||||
void onOpenProjectHistory(scope, projectRoot);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "rename",
|
||||
icon: <Pencil size={13} />,
|
||||
label: t("projectTree.renameProject"),
|
||||
onSelect: () => startRenameProject(key, projectRoot, projectLabel),
|
||||
},
|
||||
{ type: "separator" as const, key: "color-separator" },
|
||||
...PROJECT_COLOR_OPTIONS.map((option): ContextMenuItem => ({
|
||||
key: `color-${option.key || "default"}`,
|
||||
label: colorMenuLabel(projectColorLabel(t, option.key), option.key, (node.projectColor || "") === option.key),
|
||||
onSelect: () => {
|
||||
void setProjectColor(colorTargetRoot, option.key);
|
||||
},
|
||||
})),
|
||||
{ type: "separator" as const, key: "path-separator" },
|
||||
{
|
||||
key: "reveal",
|
||||
icon: <FolderOpen size={13} />,
|
||||
label: t(revealLabelKey(platform)),
|
||||
disabled: !projectPath,
|
||||
onSelect: () => {
|
||||
void app.RevealPath(projectPath);
|
||||
closeMenu();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy-path",
|
||||
icon: <Copy size={13} />,
|
||||
label: t("projectTree.copyPath"),
|
||||
disabled: !projectPath,
|
||||
onSelect: () => {
|
||||
void copyProjectPath(projectPath);
|
||||
closeMenu();
|
||||
},
|
||||
},
|
||||
...(scope === "project"
|
||||
? [
|
||||
{ type: "separator" as const, key: "remove-separator" },
|
||||
{
|
||||
key: "remove",
|
||||
icon: <XCircle size={13} />,
|
||||
label: confirmRemoveProject === key ? t("projectTree.confirmRemoveProject") : t("projectTree.removeProject"),
|
||||
danger: true,
|
||||
onSelect: () => {
|
||||
if (confirmRemoveProject === key) void removeProject(projectPath);
|
||||
else setConfirmRemoveProject(key);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (editingProject?.key === key) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className={`project-tree__folder project-tree__folder--editing${projectActive ? " project-tree__folder--active" : ""}`}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
className="project-tree__folder-input"
|
||||
value={projectDraft}
|
||||
onChange={(event) => setProjectDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void commitRenameProject(projectRoot);
|
||||
if (event.key === "Escape") setEditingProject(null);
|
||||
}}
|
||||
onBlur={() => void commitRenameProject(projectRoot)}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="project-tree__children">
|
||||
{children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className={`project-tree__folder${projectActive ? " project-tree__folder--active" : ""}${menuProject?.key === key ? " project-tree__folder--menu-open" : ""}${dragProjectRoot === projectRoot ? " project-tree__folder--dragging" : ""}${projectDropPosition ? ` project-tree__folder--drop-${projectDropPosition}` : ""}`}
|
||||
style={projectAccentStyle(node.projectColor)}
|
||||
draggable={draggableProject}
|
||||
aria-grabbed={draggableProject ? dragProjectRoot === projectRoot : undefined}
|
||||
onDragStart={handleProjectDragStart}
|
||||
onDragOver={handleProjectDragOver}
|
||||
onDragLeave={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDropProject(null);
|
||||
}}
|
||||
onDrop={handleProjectDrop}
|
||||
onDragEnd={clearProjectDrag}
|
||||
onContextMenu={openProjectMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="project-tree__folder-main"
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleExpand(key);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
||||
openProjectMenu(event);
|
||||
}
|
||||
}}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
>
|
||||
{hasChildren ? (
|
||||
isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
<Folder size={12} />
|
||||
<span className="project-tree__folder-color" aria-hidden="true" />
|
||||
<span className="project-tree__folder-label">{projectLabel}</span>
|
||||
</button>
|
||||
<Tooltip label={t("projectTree.newTopicTooltip")} className="project-tree__action-slot">
|
||||
<button
|
||||
type="button"
|
||||
className={`project-tree__new-topic${creatingProject === key ? " project-tree__new-topic--active" : ""}`}
|
||||
disabled={creatingProject !== null}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleCreateTopic(scope, projectRoot, key);
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ContextMenu
|
||||
open={menuProject?.key === key}
|
||||
point={menuPoint}
|
||||
items={projectMenuItems}
|
||||
minWidth={212}
|
||||
ariaLabel={t("projectTree.projectActions")}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="project-tree__children">
|
||||
{children.map((child) => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="project-tree">
|
||||
<label className="project-tree__search">
|
||||
<Search size={14} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("projectTree.searchPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<div className="project-tree__header">
|
||||
<span className="project-tree__header-title">
|
||||
<BriefcaseBusiness size={13} />
|
||||
{t("projectTree.workspaceTitle")}
|
||||
</span>
|
||||
<Tooltip label={t("projectTree.addProjectTooltip")} className="project-tree__action-slot">
|
||||
<button
|
||||
type="button"
|
||||
className="project-tree__add-project"
|
||||
aria-label={t("projectTree.addProjectTooltip")}
|
||||
disabled={addingProject}
|
||||
onClick={() => void handleAddProject()}
|
||||
>
|
||||
<FolderPlus size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="project-tree__list">
|
||||
{visibleTree.length === 0 ? (
|
||||
query.trim() ? (
|
||||
<div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>
|
||||
) : (
|
||||
<div className="project-tree__empty-state">
|
||||
<div className="project-tree__empty-icon">
|
||||
<FolderPlus size={18} />
|
||||
</div>
|
||||
<div className="project-tree__empty-copy">
|
||||
<strong>{t("projectTree.emptyNoProjects")}</strong>
|
||||
<span>{t("projectTree.emptyNoProjectsDesc")}</span>
|
||||
</div>
|
||||
<div className="project-tree__empty-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="project-tree__empty-primary"
|
||||
onClick={() => void handleAddProject()}
|
||||
disabled={addingProject}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
<span>{t("projectTree.emptyAddProject")}</span>
|
||||
</button>
|
||||
{onUseCurrentProject && currentWorkspaceName && (
|
||||
<button
|
||||
type="button"
|
||||
className="project-tree__empty-secondary"
|
||||
onClick={async () => {
|
||||
await onUseCurrentProject();
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
<FolderGit2 size={14} />
|
||||
<span>{t("projectTree.emptyUseCurrentDir")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{currentWorkspaceName && <div className="project-tree__empty-current">{t("projectTree.emptyCurrentDir", { dir: currentWorkspaceName })}</div>}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
visibleTree.map((node) => renderNode(node, 0))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export function PromptShelf({
|
||||
crumbs,
|
||||
quickActions,
|
||||
barRef,
|
||||
actionsWrap = false,
|
||||
}: {
|
||||
titleId: string;
|
||||
title: ReactNode;
|
||||
@@ -21,9 +22,10 @@ export function PromptShelf({
|
||||
crumbs?: ReactNode;
|
||||
quickActions?: ReactNode;
|
||||
barRef?: RefObject<HTMLDivElement>;
|
||||
actionsWrap?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="prompt-shelf" aria-live="polite">
|
||||
<div className={`prompt-shelf${actionsWrap ? " prompt-shelf--actions-wrap" : ""}`} aria-live="polite">
|
||||
<div
|
||||
ref={barRef}
|
||||
className="prompt-shelf__bar"
|
||||
@@ -36,8 +38,8 @@ export function PromptShelf({
|
||||
<PauseCircle size={16} aria-hidden="true" />
|
||||
<div className="prompt-shelf__copy">
|
||||
<div id={titleId} className="prompt-shelf__title">
|
||||
{title}
|
||||
{badges}
|
||||
<span className="prompt-shelf__heading">{title}</span>
|
||||
{badges && <span className="prompt-shelf__badges">{badges}</span>}
|
||||
</div>
|
||||
<div className="prompt-shelf__meta">{meta}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useI18n, useT } from "../lib/i18n";
|
||||
import { normalizeLangPref, useI18n, useT, type LangPref } from "../lib/i18n";
|
||||
import { useUpdater } from "../lib/useUpdater";
|
||||
import {
|
||||
THEME_STYLES,
|
||||
@@ -9,21 +10,25 @@ import {
|
||||
getResolvedTheme,
|
||||
getTheme,
|
||||
getThemeStyle,
|
||||
normalizeThemePreference,
|
||||
normalizeThemeStyleForTheme,
|
||||
themeForStyle,
|
||||
type Theme,
|
||||
type ThemeStyle,
|
||||
} from "../lib/theme";
|
||||
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
|
||||
import type { NetworkView, ProviderView, SettingsView } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
type SettingsTab = "models" | "providers" | "network" | "permissions" | "sandbox" | "agent" | "appearance" | "updates";
|
||||
type SettingsTab = "general" | "models" | "providers" | "network" | "permissions" | "sandbox" | "appearance" | "updates";
|
||||
|
||||
const SETTINGS_TABS: SettingsTab[] = ["models", "providers", "network", "permissions", "sandbox", "agent", "appearance", "updates"];
|
||||
const SETTINGS_TABS: SettingsTab[] = ["general", "models", "providers", "network", "permissions", "sandbox", "appearance", "updates"];
|
||||
|
||||
// SettingsPanel is the desktop settings surface, aligning with Claude Code's
|
||||
// settings: model & providers (incl. API keys), permissions, sandbox, agent
|
||||
// params, and appearance. Every change writes reasonix.toml (or .env for keys)
|
||||
// settings: model & providers (incl. API keys), permissions, sandbox, and
|
||||
// appearance. Every change writes reasonix.toml (or .env for keys)
|
||||
// through the kernel's config edit API and rebuilds the controller live.
|
||||
export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onChanged: () => void }) {
|
||||
const t = useT();
|
||||
@@ -32,12 +37,20 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [theme, setThemeState] = useState<Theme>(getTheme());
|
||||
const [themeStyle, setThemeStyleState] = useState<ThemeStyle>(() => getThemeStyle(getTheme()));
|
||||
const [tab, setTab] = useState<SettingsTab>("models");
|
||||
const [textSize, setTextSizeState] = useState<TextSize>(getTextSize());
|
||||
const [tab, setTab] = useState<SettingsTab>("general");
|
||||
|
||||
const reload = async () => setS(await app.Settings().catch(() => null));
|
||||
const reload = async () => setS(normalizeSettingsView(await app.Settings().catch(() => null)));
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!s) return;
|
||||
const nextTheme = normalizeThemePreference(s.desktopTheme);
|
||||
const nextStyle = normalizeThemeStyleForTheme(s.desktopThemeStyle, nextTheme);
|
||||
setThemeState(nextTheme);
|
||||
setThemeStyleState(nextStyle);
|
||||
}, [s?.desktopTheme, s?.desktopThemeStyle]);
|
||||
|
||||
// apply runs a mutation, re-reads settings, and refreshes the topbar/model. A
|
||||
// rejected binding (validation / rebuild failure) surfaces as an inline banner.
|
||||
@@ -85,27 +98,34 @@ export function SettingsPanel({ onClose, onChanged }: { onClose: () => void; onC
|
||||
</nav>
|
||||
<main className="settings-content">
|
||||
{err && <div className="banner banner--error">{err}</div>}
|
||||
{tab === "general" && <GeneralSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "models" && <ModelsSection s={s} busy={busy} apply={apply} onManageProviders={() => setTab("providers")} />}
|
||||
{tab === "providers" && <ProvidersSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "network" && <NetworkSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "permissions" && <PermissionsSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "sandbox" && <SandboxSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "agent" && <AgentSection s={s} busy={busy} apply={apply} />}
|
||||
{tab === "appearance" && (
|
||||
<AppearanceSection
|
||||
theme={theme}
|
||||
themeStyle={themeStyle}
|
||||
textSize={textSize}
|
||||
onTheme={(t) => {
|
||||
const nextStyle = themeForStyle(themeStyle) === getResolvedTheme(t) ? themeStyle : defaultStyleForTheme(t);
|
||||
applyTheme(t, nextStyle);
|
||||
applyTheme(t, nextStyle, { persist: false });
|
||||
setThemeState(t);
|
||||
setThemeStyleState(nextStyle);
|
||||
void apply(() => app.SetDesktopAppearance(t, nextStyle));
|
||||
}}
|
||||
onThemeStyle={(style) => {
|
||||
const nextTheme = themeForStyle(style);
|
||||
applyTheme(nextTheme, style);
|
||||
applyTheme(nextTheme, style, { persist: false });
|
||||
setThemeState(nextTheme);
|
||||
setThemeStyleState(style);
|
||||
void apply(() => app.SetDesktopAppearance(nextTheme, style));
|
||||
}}
|
||||
onTextSize={(size) => {
|
||||
applyTextSize(size);
|
||||
setTextSizeState(size);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -128,6 +148,8 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
|
||||
switch (id) {
|
||||
case "models":
|
||||
return t("settings.tab.models");
|
||||
case "general":
|
||||
return t("settings.tab.general");
|
||||
case "providers":
|
||||
return t("settings.tab.providers");
|
||||
case "network":
|
||||
@@ -136,8 +158,6 @@ function settingsTabLabel(id: SettingsTab, t: ReturnType<typeof useT>): string {
|
||||
return t("settings.tab.permissions");
|
||||
case "sandbox":
|
||||
return t("settings.tab.sandbox");
|
||||
case "agent":
|
||||
return t("settings.tab.agent");
|
||||
case "appearance":
|
||||
return t("settings.tab.appearance");
|
||||
case "updates":
|
||||
@@ -149,6 +169,8 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
|
||||
switch (id) {
|
||||
case "models":
|
||||
return toRef(s.defaultModel, s) || t("common.none");
|
||||
case "general":
|
||||
return `${closeBehaviorLabel(normalizeCloseBehavior(s.closeBehavior), t)} · ${t(`settings.autoPlan.${normalizeAutoPlan(s.autoPlan)}`)}`;
|
||||
case "providers":
|
||||
return t("settings.providerCount", { n: s.providers.length });
|
||||
case "network":
|
||||
@@ -157,8 +179,6 @@ function settingsTabMeta(id: SettingsTab, s: SettingsView, t: ReturnType<typeof
|
||||
return s.permissions.mode;
|
||||
case "sandbox":
|
||||
return s.sandbox.bash;
|
||||
case "agent":
|
||||
return t("settings.agentMeta", { temp: s.agent.temperature, steps: s.agent.maxSteps || "∞" });
|
||||
case "appearance":
|
||||
return t("settings.appearanceMeta");
|
||||
case "updates":
|
||||
@@ -186,9 +206,18 @@ function toRef(model: string, s: SettingsView): string {
|
||||
}
|
||||
|
||||
const PROXY_MODES = ["auto", "custom", "off"] as const;
|
||||
|
||||
// EFFORT_PRESETS is the canonical union of /effort levels the kernel
|
||||
// recognises. The settings UI exposes these as toggleable checkboxes; users
|
||||
// can additionally add arbitrary custom names via the "Add" input. The order
|
||||
// here is what the user sees in the dropdown.
|
||||
const EFFORT_PRESETS: readonly string[] = ["low", "medium", "high", "xhigh", "max"];
|
||||
const PROXY_TYPES = ["http", "https", "socks5", "socks5h"] as const;
|
||||
const LANGUAGE_PREFS: LangPref[] = ["", "zh", "en"];
|
||||
const AUTO_PLAN_MODES = ["off", "on"] as const;
|
||||
|
||||
type ProxyMode = (typeof PROXY_MODES)[number];
|
||||
type AutoPlanMode = (typeof AUTO_PLAN_MODES)[number];
|
||||
|
||||
function normalizeProxyMode(mode: string): ProxyMode {
|
||||
switch (mode) {
|
||||
@@ -205,6 +234,119 @@ function normalizeNetworkView(network: NetworkView): NetworkView {
|
||||
return { ...network, proxyMode: normalizeProxyMode(network.proxyMode) };
|
||||
}
|
||||
|
||||
function normalizeAutoPlan(mode: string | undefined): AutoPlanMode {
|
||||
return mode === "ask" || mode === "on" ? "on" : "off";
|
||||
}
|
||||
|
||||
function normalizeSettingsView(view: SettingsView | null | undefined): SettingsView | null {
|
||||
if (!view) return null;
|
||||
const permissions = view.permissions ?? { mode: "ask", allow: [], ask: [], deny: [] };
|
||||
const sandbox = view.sandbox ?? { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [] };
|
||||
const network = view.network ?? {
|
||||
proxyMode: "auto",
|
||||
proxyUrl: "",
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "", port: 0, username: "", password: "" },
|
||||
};
|
||||
const agent = view.agent ?? { temperature: 0, maxSteps: 0, systemPrompt: "" };
|
||||
return {
|
||||
...view,
|
||||
providers: asArray(view.providers).map((p) => ({ ...p, models: asArray(p.models) })),
|
||||
providerKinds: asArray(view.providerKinds),
|
||||
permissions: {
|
||||
...permissions,
|
||||
allow: asArray(permissions.allow),
|
||||
ask: asArray(permissions.ask),
|
||||
deny: asArray(permissions.deny),
|
||||
},
|
||||
sandbox: {
|
||||
...sandbox,
|
||||
allowWrite: asArray(sandbox.allowWrite),
|
||||
},
|
||||
network: {
|
||||
...network,
|
||||
proxy: network.proxy ?? { type: "socks5", server: "", port: 0, username: "", password: "" },
|
||||
},
|
||||
agent,
|
||||
autoPlan: normalizeAutoPlan(view.autoPlan),
|
||||
desktopLanguage: normalizeLangPref(view.desktopLanguage),
|
||||
desktopTheme: normalizeThemePreference(view.desktopTheme),
|
||||
desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)),
|
||||
closeBehavior: normalizeCloseBehavior(view.closeBehavior),
|
||||
};
|
||||
}
|
||||
|
||||
type CloseBehavior = "background" | "quit";
|
||||
|
||||
function normalizeCloseBehavior(mode: string | undefined): CloseBehavior {
|
||||
return mode === "quit" ? "quit" : "background";
|
||||
}
|
||||
|
||||
function closeBehaviorLabel(mode: CloseBehavior, t: ReturnType<typeof useT>): string {
|
||||
return mode === "quit" ? t("settings.closeBehavior.quit") : t("settings.closeBehavior.background");
|
||||
}
|
||||
|
||||
function GeneralSection({ s, busy, apply }: SectionProps) {
|
||||
const { t, setPref } = useI18n();
|
||||
const closeBehavior = normalizeCloseBehavior(s.closeBehavior);
|
||||
const autoPlan = normalizeAutoPlan(s.autoPlan);
|
||||
const languagePref = normalizeLangPref(s.desktopLanguage);
|
||||
const setLanguage = (next: LangPref) => {
|
||||
setPref(next);
|
||||
void apply(() => app.SetDesktopLanguage(next));
|
||||
};
|
||||
return (
|
||||
<section className="mem-section">
|
||||
<div className="mem-section__title">{t("settings.tab.general")}</div>
|
||||
<div className="set-row">
|
||||
<label className="set-label">{t("settings.language")}</label>
|
||||
<div className="set-seg">
|
||||
{LANGUAGE_PREFS.map((pref) => (
|
||||
<button
|
||||
key={pref || "auto"}
|
||||
className={`set-seg__btn${languagePref === pref ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => setLanguage(pref)}
|
||||
>
|
||||
{pref === "" ? t("settings.langAuto") : pref === "zh" ? "中文" : "English"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<label className="set-label">{t("settings.closeBehavior")}</label>
|
||||
<div className="set-seg">
|
||||
{(["background", "quit"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`set-seg__btn${closeBehavior === mode ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => void apply(() => app.SetCloseBehavior(mode))}
|
||||
>
|
||||
{closeBehaviorLabel(mode, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<label className="set-label">{t("settings.autoPlan")}</label>
|
||||
<div className="set-seg">
|
||||
{AUTO_PLAN_MODES.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`set-seg__btn${autoPlan === mode ? " set-seg__btn--on" : ""}`}
|
||||
disabled={busy}
|
||||
onClick={() => void apply(() => app.SetAutoPlan(mode))}
|
||||
>
|
||||
{t(`settings.autoPlan.${mode}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkSection({ s, busy, apply }: SectionProps) {
|
||||
const t = useT();
|
||||
const savedNetwork = normalizeNetworkView(s.network);
|
||||
@@ -332,6 +474,9 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
|
||||
const defaultRef = toRef(s.defaultModel, s);
|
||||
const plannerRef = toRef(s.plannerModel, s);
|
||||
const [defaultProvider, defaultModel] = defaultRef.split("/");
|
||||
const plannerModeDetail = plannerRef
|
||||
? t("settings.plannerDualDetail", { planner: plannerRef, executor: defaultRef || t("common.none") })
|
||||
: t("settings.plannerSingleDetail", { model: defaultRef || t("common.none") });
|
||||
|
||||
return (
|
||||
<section className="mem-section">
|
||||
@@ -383,8 +528,8 @@ function ModelsSection({ s, busy, apply, onManageProviders }: SectionProps & { o
|
||||
</div>
|
||||
<div>
|
||||
<span>{t("settings.plannerStatus")}</span>
|
||||
<strong>{plannerRef ? t("settings.plannerEnabled") : t("settings.plannerDisabled")}</strong>
|
||||
<small>{plannerRef || t("settings.plannerNone")}</small>
|
||||
<strong>{plannerRef ? t("settings.plannerDual") : t("settings.plannerSingle")}</strong>
|
||||
<small>{plannerModeDetail}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -453,15 +598,22 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => setEditing(p.name)}>
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
<Tooltip label={defaultProvider === p.name ? t("settings.cantDeleteDefault") : t("settings.deleteProvider")}>
|
||||
<button
|
||||
className="btn btn--small"
|
||||
disabled={busy || defaultProvider === p.name}
|
||||
onClick={() => void apply(() => app.DeleteProvider(p.name))}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{defaultProvider === p.name ? (
|
||||
<Tooltip label={t("settings.cantDeleteDefault")}>
|
||||
<button className="btn btn--small" disabled>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<InlineConfirmButton
|
||||
label={t("common.delete")}
|
||||
confirmLabel={t("settings.confirmDeleteProvider")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={() => apply(() => app.DeleteProvider(p.name))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="prov-card__meta">
|
||||
<span>{p.kind}</span>
|
||||
@@ -509,11 +661,43 @@ function ProviderEditor({
|
||||
// Empty when unset so the placeholder (and its "0 = default" hint) reads instead
|
||||
// of a bare "0"; saved back as 0.
|
||||
const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
|
||||
const [supportedEfforts, setSupportedEfforts] = useState<string[]>(initial?.supportedEfforts ?? []);
|
||||
const [customEffortDraft, setCustomEffortDraft] = useState("");
|
||||
const [defaultEffort, setDefaultEffort] = useState(initial?.defaultEffort ?? "");
|
||||
|
||||
// Offer the kinds the kernel actually registered; if the stored kind is a
|
||||
// legacy/unknown one, keep it as an option so editing doesn't silently change it.
|
||||
const kindOptions = kind && !kinds.includes(kind) ? [kind, ...kinds] : kinds;
|
||||
|
||||
// Split supportedEfforts into the 5 canonical presets (for checkbox UI) and
|
||||
// any user-added custom names (rendered as removable chips). The preset order
|
||||
// is fixed; custom names keep insertion order.
|
||||
const presetEfforts = supportedEfforts.filter((e) => EFFORT_PRESETS.includes(e));
|
||||
const customEfforts = supportedEfforts.filter((e) => !EFFORT_PRESETS.includes(e));
|
||||
|
||||
const togglePreset = (level: string) => {
|
||||
const has = presetEfforts.includes(level);
|
||||
const nextPresets = has ? presetEfforts.filter((e) => e !== level) : [...presetEfforts, level];
|
||||
setSupportedEfforts([...nextPresets, ...customEfforts]);
|
||||
// If the removed preset was the default, fall back to "auto" (empty string).
|
||||
if (has && defaultEffort === level) setDefaultEffort("");
|
||||
};
|
||||
|
||||
const addCustomEffort = () => {
|
||||
const v = customEffortDraft.trim().toLowerCase();
|
||||
if (!v || supportedEfforts.includes(v)) {
|
||||
setCustomEffortDraft("");
|
||||
return;
|
||||
}
|
||||
setSupportedEfforts([...presetEfforts, ...customEfforts, v]);
|
||||
setCustomEffortDraft("");
|
||||
};
|
||||
|
||||
const removeCustomEffort = (level: string) => {
|
||||
setSupportedEfforts(supportedEfforts.filter((e) => e !== level));
|
||||
if (defaultEffort === level) setDefaultEffort("");
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const ms = models
|
||||
.split(",")
|
||||
@@ -529,6 +713,10 @@ function ProviderEditor({
|
||||
keySet: initial?.keySet ?? false,
|
||||
balanceUrl: balanceUrl.trim(),
|
||||
contextWindow: Number(ctx) || 0,
|
||||
supportedEfforts,
|
||||
// Clear the stored default if no levels are selected; the backend's
|
||||
// NormalizeEffort would otherwise silently ignore an unsupported value.
|
||||
defaultEffort: supportedEfforts.length > 0 ? defaultEffort : "",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -552,6 +740,81 @@ function ProviderEditor({
|
||||
<label className="set-label">{t("settings.providerContextWindow")}</label>
|
||||
<input className="mem-input" placeholder={t("settings.contextWindowPlaceholder")} value={ctx} onChange={(e) => setCtx(e.target.value)} inputMode="numeric" />
|
||||
<div className="mem-hint">{t("settings.contextWindowHint")}</div>
|
||||
<label className="set-label">{t("settings.supportedEfforts")}</label>
|
||||
{EFFORT_PRESETS.map((level) => (
|
||||
<label key={level} className="set-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={presetEfforts.includes(level)}
|
||||
onChange={() => togglePreset(level)}
|
||||
/>
|
||||
{level}
|
||||
</label>
|
||||
))}
|
||||
<div className="set-row">
|
||||
<input
|
||||
className="mem-input set-grow"
|
||||
placeholder={t("settings.supportedEffortsCustomPlaceholder")}
|
||||
value={customEffortDraft}
|
||||
onChange={(e) => setCustomEffortDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCustomEffort();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--small"
|
||||
disabled={
|
||||
!customEffortDraft.trim() || supportedEfforts.includes(customEffortDraft.trim().toLowerCase())
|
||||
}
|
||||
onClick={addCustomEffort}
|
||||
>
|
||||
{t("common.add")}
|
||||
</button>
|
||||
</div>
|
||||
{customEfforts.length > 0 && (
|
||||
<div className="set-rules__chips">
|
||||
{customEfforts.map((level) => (
|
||||
<span className="set-rule" key={level}>
|
||||
{level}
|
||||
<Tooltip label={t("common.delete")}>
|
||||
<button
|
||||
type="button"
|
||||
className="set-rule__x"
|
||||
disabled={busy}
|
||||
onClick={() => removeCustomEffort(level)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mem-hint">{t("settings.supportedEffortsHint")}</div>
|
||||
<label className="set-label">{t("settings.defaultEffort")}</label>
|
||||
{supportedEfforts.length > 0 ? (
|
||||
<select
|
||||
className="mem-select"
|
||||
value={defaultEffort}
|
||||
onChange={(e) => setDefaultEffort(e.target.value)}
|
||||
>
|
||||
<option value="">{t("settings.defaultEffortAuto")}</option>
|
||||
{supportedEfforts.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select className="mem-select" value="" disabled>
|
||||
<option value="">{t("settings.defaultEffortAuto")}</option>
|
||||
</select>
|
||||
)}
|
||||
<div className="mem-hint">{t("settings.defaultEffortHint")}</div>
|
||||
<div className="prov-card__actions">
|
||||
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
@@ -725,50 +988,22 @@ function SandboxSection({ s, busy, apply }: SectionProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSection({ s, busy, apply }: SectionProps) {
|
||||
const t = useT();
|
||||
const [temp, setTemp] = useState(String(s.agent.temperature));
|
||||
const [steps, setSteps] = useState(String(s.agent.maxSteps));
|
||||
const [prompt, setPrompt] = useState(s.agent.systemPrompt);
|
||||
const dirty = temp !== String(s.agent.temperature) || steps !== String(s.agent.maxSteps) || prompt !== s.agent.systemPrompt;
|
||||
|
||||
return (
|
||||
<section className="mem-section">
|
||||
<div className="mem-section__title">{t("settings.agent")}</div>
|
||||
<div className="set-row">
|
||||
<label className="set-label">{t("settings.temperature")}</label>
|
||||
<input className="mem-input set-narrow" value={temp} onChange={(e) => setTemp(e.target.value)} disabled={busy} inputMode="decimal" />
|
||||
<label className="set-label">{t("settings.maxSteps")}</label>
|
||||
<input className="mem-input set-narrow" value={steps} onChange={(e) => setSteps(e.target.value)} disabled={busy} inputMode="numeric" />
|
||||
<span className="mem-hint">{t("settings.unlimited")}</span>
|
||||
</div>
|
||||
<div className="set-rules__label">{t("settings.systemPrompt")}</div>
|
||||
<textarea className="mem-textarea" value={prompt} onChange={(e) => setPrompt(e.target.value)} disabled={busy} spellCheck={false} />
|
||||
<div className="prov-card__actions">
|
||||
<button
|
||||
className="btn btn--primary btn--small"
|
||||
disabled={busy || !dirty}
|
||||
onClick={() => void apply(() => app.SetAgentParams(Number(temp) || 0, Number(steps) || 0, prompt))}
|
||||
>
|
||||
{t("settings.saveAgent")}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function AppearanceSection({
|
||||
theme,
|
||||
themeStyle,
|
||||
textSize,
|
||||
onTheme,
|
||||
onThemeStyle,
|
||||
onTextSize,
|
||||
}: {
|
||||
theme: Theme;
|
||||
themeStyle: ThemeStyle;
|
||||
textSize: TextSize;
|
||||
onTheme: (t: Theme) => void;
|
||||
onThemeStyle: (style: ThemeStyle) => void;
|
||||
onTextSize: (size: TextSize) => void;
|
||||
}) {
|
||||
const { t, pref, setPref } = useI18n();
|
||||
const t = useT();
|
||||
const themeOptions: Theme[] = ["auto", "light", "dark"];
|
||||
return (
|
||||
<section className="mem-section">
|
||||
@@ -803,12 +1038,18 @@ function AppearanceSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="set-row">
|
||||
<label className="set-label">{t("settings.language")}</label>
|
||||
<select className="mem-select set-grow" value={pref} onChange={(e) => setPref(e.target.value as "" | "en" | "zh")}>
|
||||
<option value="">{t("settings.langAuto")}</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<label className="set-label">{t("settings.textSize")}</label>
|
||||
<div className="set-seg">
|
||||
{TEXT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className={`set-seg__btn${textSize === size ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onTextSize(size)}
|
||||
>
|
||||
{textSizeName(size, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
@@ -825,6 +1066,19 @@ function themeName(theme: Theme, t: ReturnType<typeof useT>): string {
|
||||
}
|
||||
}
|
||||
|
||||
function textSizeName(size: TextSize, t: ReturnType<typeof useT>): string {
|
||||
switch (size) {
|
||||
case "small":
|
||||
return t("settings.textSizeSmall");
|
||||
case "default":
|
||||
return t("settings.textSizeDefault");
|
||||
case "large":
|
||||
return t("settings.textSizeLarge");
|
||||
case "xlarge":
|
||||
return t("settings.textSizeXLarge");
|
||||
}
|
||||
}
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
const mb = (n: number) => (n / MB).toFixed(1);
|
||||
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Coins, Cpu, Wallet } from "lucide-react";
|
||||
import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { useState } from "react";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
|
||||
import type { BalanceInfo, ContextInfo, EffortInfo, JobView, Meta, Mode, WireUsage } from "../lib/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import type { BalanceInfo, ContextInfo, JobView, Mode, WireUsage } from "../lib/types";
|
||||
|
||||
// JobsChip is the status-bar background-jobs indicator: a count that opens an
|
||||
// upward popover listing the running jobs (id · label · status), mirroring the
|
||||
// ModelSwitcher's click-to-open pattern. It renders nothing when there are no
|
||||
// jobs, so the caller guards on jobs.length first.
|
||||
// ModelSwitcher's click-to-open pattern. With no jobs it still reserves a stable
|
||||
// "jobs 0" slot so the IDE-style status order does not jump.
|
||||
function JobsChip({ jobs }: { jobs: JobView[] }) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
if (jobs.length === 0) {
|
||||
return (
|
||||
<span className="statusbar__item">
|
||||
{t("status.jobsCount", { n: 0 })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="statusbar__jobswrap">
|
||||
<Tooltip label={t("status.jobsTitle")}>
|
||||
<button className="statusbar__jobs" onClick={() => setOpen((v) => !v)}>
|
||||
<Cpu size={11} />
|
||||
{t("status.jobs", { n: jobs.length })}
|
||||
<button className="statusbar__item statusbar__jobs" onClick={() => setOpen((v) => !v)}>
|
||||
{t("status.jobsCount", { n: jobs.length })}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
@@ -40,162 +43,94 @@ function JobsChip({ jobs }: { jobs: JobView[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function formatRate(hit: number, denom: number): string | null {
|
||||
if (denom <= 0) return null;
|
||||
return ((hit / denom) * 100).toFixed(2);
|
||||
}
|
||||
|
||||
// nowRate is the SINGLE-TURN prompt cache-hit % (latest turn) — the higher,
|
||||
// steeper number on a non-compacting DeepSeek session. null when nothing yet.
|
||||
function nowRate(u?: WireUsage): number | null {
|
||||
function nowRate(u?: WireUsage): string | null {
|
||||
if (!u) return null;
|
||||
let denom = u.cacheHitTokens + u.cacheMissTokens;
|
||||
if (denom === 0) denom = u.promptTokens;
|
||||
if (denom <= 0) return null;
|
||||
return Math.round((u.cacheHitTokens / denom) * 100);
|
||||
return formatRate(u.cacheHitTokens, denom);
|
||||
}
|
||||
|
||||
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
|
||||
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
|
||||
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
|
||||
// drag the average down); it overtakes only when compaction craters single turns.
|
||||
function avgRate(u?: WireUsage): number | null {
|
||||
function avgRate(u?: WireUsage): string | null {
|
||||
if (!u) return null;
|
||||
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
|
||||
if (denom <= 0) return null;
|
||||
return Math.round((u.sessionCacheHitTokens / denom) * 100);
|
||||
return formatRate(u.sessionCacheHitTokens, denom);
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||
return String(n);
|
||||
function currencySymbol(currency?: string): string {
|
||||
const value = (currency || "¥").trim();
|
||||
if (/^(cny|rmb|yuan)$/i.test(value)) return "¥";
|
||||
if (/^(usd|dollar)$/i.test(value)) return "$";
|
||||
return value || "¥";
|
||||
}
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
return `${Math.floor(s / 60)}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
// useTick re-renders once a second while `on`, so the elapsed clock advances.
|
||||
function useTick(on: boolean): number {
|
||||
const [, setN] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!on) return;
|
||||
const id = setInterval(() => setN((n) => n + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [on]);
|
||||
return Date.now();
|
||||
function formatMoney(amount?: number, currency?: string): string {
|
||||
const symbol = currencySymbol(currency);
|
||||
if (typeof amount !== "number" || amount <= 0) return `${symbol}0.0000`;
|
||||
return `${symbol}${amount < 1 ? amount.toFixed(4) : amount.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function StatusBar({
|
||||
meta,
|
||||
context,
|
||||
usage,
|
||||
balance,
|
||||
effort,
|
||||
jobs,
|
||||
running,
|
||||
mode,
|
||||
turnStartAt,
|
||||
turnTokens,
|
||||
cost,
|
||||
retry,
|
||||
onSwitchModel,
|
||||
onSetEffort,
|
||||
currency,
|
||||
}: {
|
||||
meta?: Meta;
|
||||
context: ContextInfo;
|
||||
usage?: WireUsage;
|
||||
balance?: BalanceInfo;
|
||||
effort?: EffortInfo;
|
||||
jobs?: JobView[];
|
||||
running: boolean;
|
||||
mode: Mode;
|
||||
turnStartAt: number;
|
||||
turnTokens: number;
|
||||
cost?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
onSwitchModel: (name: string) => void;
|
||||
onSetEffort: (level: string) => void;
|
||||
currency?: string;
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const now = useTick(running);
|
||||
const { t } = useI18n();
|
||||
const pct = context.window ? Math.min(100, Math.round((context.used / context.window) * 100)) : null;
|
||||
const compactPct = context.compactRatio ? Math.round(context.compactRatio * 100) : null;
|
||||
const nowPct = nowRate(usage);
|
||||
const avgPct = avgRate(usage);
|
||||
|
||||
// While a turn runs, the status line shows live activity (word · elapsed ·
|
||||
// tokens) in place of the static context gauge.
|
||||
let activity: string | null = null;
|
||||
if (retry) {
|
||||
activity = t("status.retrying", { attempt: retry.attempt, max: retry.max });
|
||||
} else if (running && turnStartAt) {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
activity = `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
}
|
||||
const jobsList = jobs ?? [];
|
||||
const costLabel = formatMoney(cost, currency);
|
||||
|
||||
return (
|
||||
<div className="statusbar">
|
||||
<span className={`statusbar__dot ${running ? "statusbar__dot--busy" : ""}`} />
|
||||
<ModelSwitcher label={meta?.label ?? t("status.connecting")} onPick={onSwitchModel} />
|
||||
{effort?.supported && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
|
||||
</>
|
||||
)}
|
||||
{activity ? (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__activity">{activity}</span>
|
||||
</>
|
||||
) : (
|
||||
pct !== null && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__ctx">{t("status.ctx", { pct })}</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{nowPct !== null && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__cache">{t("status.cache", { pct: nowPct })}</span>
|
||||
</>
|
||||
)}
|
||||
{avgPct !== null && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__cache">{t("status.cacheAvg", { pct: avgPct })}</span>
|
||||
</>
|
||||
)}
|
||||
{jobs && jobs.length > 0 && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<JobsChip jobs={jobs} />
|
||||
</>
|
||||
)}
|
||||
{typeof cost === "number" && cost > 0 && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<Tooltip label={t("status.spendTitle")}>
|
||||
<span className="statusbar__balance statusbar__cost">
|
||||
<Coins size={11} />
|
||||
{`¥${cost < 1 ? cost.toFixed(4) : cost.toFixed(2)}`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{balance?.available && balance.display && (
|
||||
<>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<Tooltip label={t("status.balanceTitle")}>
|
||||
<span className="statusbar__balance">
|
||||
<Wallet size={11} />
|
||||
{balance.display}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<span className="statusbar__item statusbar__ctx">{pct !== null ? t("status.ctx", { pct }) : t("status.ctxUnknown")}</span>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__item statusbar__compact">{compactPct !== null ? t("status.compact", { pct: compactPct }) : t("status.compactUnknown")}</span>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__item statusbar__cache">{t("status.cache", { pct: nowPct ?? "-" })}</span>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<span className="statusbar__item statusbar__avg">{t("status.cacheAvg", { pct: avgPct ?? "-" })}</span>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<Tooltip label={t("status.spendTitle")}>
|
||||
<span className="statusbar__item statusbar__cost">
|
||||
{t("status.cost", { amount: costLabel })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="statusbar__sep">·</span>
|
||||
<JobsChip jobs={jobsList} />
|
||||
<span className="statusbar__sep">·</span>
|
||||
<Tooltip label={t("status.balanceTitle")}>
|
||||
<span className="statusbar__item statusbar__balance">
|
||||
{t("status.balance", { amount: balance?.available && balance.display ? balance.display : "-" })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="statusbar__spacer" />
|
||||
{mode === "plan" && <span className="statusbar__plan">{t("status.plan")}</span>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// TabBar renders the browser-like workspace tab strip. Each tab represents one
|
||||
// open project/global topic, so switching tabs switches the active conversation.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from "react";
|
||||
import { FileText, Plus, X } from "lucide-react";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import { projectColorValue } from "../lib/projectColors";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: TabMeta[];
|
||||
activeTabId?: string;
|
||||
onTabChange: (tabId: string) => void;
|
||||
onTabClose: (tabId: string) => void;
|
||||
onTabsClose: (tabIds: string[], nextActiveTabId?: string) => void;
|
||||
onTabsReorder: (tabIds: string[]) => void;
|
||||
onNewTab: () => void;
|
||||
revealActiveSignal?: number;
|
||||
}
|
||||
|
||||
type DropSide = "before" | "after";
|
||||
|
||||
function tabDisplayTitle(tab: TabMeta): string {
|
||||
if (tab.tabType === "file" || tab.scope === "file") return tab.topicTitle?.trim() || tab.filePath?.split("/").filter(Boolean).pop() || "File";
|
||||
const title = tab.topicTitle?.trim();
|
||||
if (tab.scope === "global") return title || "Global";
|
||||
return title || "Untitled";
|
||||
}
|
||||
|
||||
function tabFullTitle(tab: TabMeta): string {
|
||||
if (tab.tabType === "file" || tab.scope === "file") return tab.filePath || tabDisplayTitle(tab);
|
||||
if (tab.scope === "global") {
|
||||
const title = tabDisplayTitle(tab);
|
||||
const workspaceName = tab.workspaceName?.trim() || "Global";
|
||||
return title === workspaceName ? workspaceName : `${workspaceName} / ${title}`;
|
||||
}
|
||||
const workspaceName = tab.workspaceName?.trim() || "Project";
|
||||
return `${workspaceName} / ${tabDisplayTitle(tab)}`;
|
||||
}
|
||||
|
||||
function tabMode(tab: TabMeta): "normal" | "plan" | "yolo" {
|
||||
return tab.mode === "plan" || tab.mode === "yolo" ? tab.mode : "normal";
|
||||
}
|
||||
|
||||
function projectAccentStyle(color?: string): CSSProperties | undefined {
|
||||
const value = projectColorValue(color);
|
||||
if (!value) return undefined;
|
||||
return { "--project-accent": value } as CSSProperties;
|
||||
}
|
||||
|
||||
export function TabBar({ tabs, activeTabId, onTabChange, onTabClose, onTabsClose, onTabsReorder, onNewTab, revealActiveSignal = 0 }: TabBarProps) {
|
||||
const t = useT();
|
||||
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<{ id: string; side: DropSide } | null>(null);
|
||||
const [menuTabId, setMenuTabId] = useState<string | null>(null);
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const tabRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const backendActiveTabId = tabs.find((tab) => tab.active)?.id;
|
||||
const activeTabIdExists = Boolean(activeTabId && tabs.some((tab) => tab.id === activeTabId));
|
||||
const resolvedActiveTabId = activeTabIdExists ? activeTabId : backendActiveTabId;
|
||||
const tabOrderKey = tabs.map((tab) => tab.id).join("\u0000");
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedActiveTabId) return;
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
tabRefs.current.get(resolvedActiveTabId)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [backendActiveTabId, resolvedActiveTabId, revealActiveSignal, tabOrderKey]);
|
||||
|
||||
const handleClose = (tabId: string) => {
|
||||
onTabClose(tabId);
|
||||
};
|
||||
|
||||
const clearDragState = () => {
|
||||
setDraggingTabId(null);
|
||||
setDropTarget(null);
|
||||
};
|
||||
|
||||
const dropSideForEvent = (event: DragEvent<HTMLButtonElement>): DropSide => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
return event.clientX > rect.left + rect.width / 2 ? "after" : "before";
|
||||
};
|
||||
|
||||
const reorderTabIds = (draggedId: string, targetId: string, side: DropSide): string[] => {
|
||||
const ids = tabs.map((tab) => tab.id);
|
||||
const from = ids.indexOf(draggedId);
|
||||
const target = ids.indexOf(targetId);
|
||||
if (from < 0 || target < 0 || draggedId === targetId) return ids;
|
||||
const next = ids.filter((id) => id !== draggedId);
|
||||
const targetAfterRemoval = next.indexOf(targetId);
|
||||
const insertAt = side === "after" ? targetAfterRemoval + 1 : targetAfterRemoval;
|
||||
next.splice(insertAt, 0, draggedId);
|
||||
return next;
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
|
||||
setDraggingTabId(tabId);
|
||||
setDropTarget(null);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", tabId);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
|
||||
if (!draggingTabId || draggingTabId === tabId) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDropTarget({ id: tabId, side: dropSideForEvent(event) });
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent<HTMLButtonElement>, tabId: string) => {
|
||||
event.preventDefault();
|
||||
const draggedId = draggingTabId || event.dataTransfer.getData("text/plain");
|
||||
const side = dropTarget?.id === tabId ? dropTarget.side : dropSideForEvent(event);
|
||||
clearDragState();
|
||||
if (!draggedId || draggedId === tabId) return;
|
||||
const next = reorderTabIds(draggedId, tabId, side);
|
||||
if (next.join("\u0000") !== tabs.map((tab) => tab.id).join("\u0000")) {
|
||||
suppressClickRef.current = true;
|
||||
onTabsReorder(next);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
return;
|
||||
}
|
||||
onTabChange(tabId);
|
||||
};
|
||||
|
||||
const openTabMenu = (event: ReactMouseEvent<HTMLButtonElement> | ReactKeyboardEvent<HTMLButtonElement>, tabId: string) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMenuTabId(tabId);
|
||||
setMenuPoint(contextMenuPointFromEvent(event));
|
||||
};
|
||||
|
||||
const closeTabMenu = () => {
|
||||
setMenuTabId(null);
|
||||
setMenuPoint(null);
|
||||
};
|
||||
|
||||
const closeTabsFromMenu = (tabIds: string[], nextActiveTabId?: string) => {
|
||||
closeTabMenu();
|
||||
onTabsClose(tabIds, nextActiveTabId);
|
||||
};
|
||||
|
||||
const menuTabIndex = menuTabId ? tabs.findIndex((tab) => tab.id === menuTabId) : -1;
|
||||
const tabMenuItems: ContextMenuItem[] = menuTabId && menuTabIndex >= 0
|
||||
? [
|
||||
{
|
||||
key: "close-current",
|
||||
label: t("tabBar.closeTab"),
|
||||
disabled: tabs.length <= 1,
|
||||
onSelect: () => closeTabsFromMenu([menuTabId]),
|
||||
},
|
||||
{
|
||||
key: "close-other",
|
||||
label: t("tabBar.closeOtherTabs"),
|
||||
disabled: tabs.length <= 1,
|
||||
onSelect: () => closeTabsFromMenu(tabs.filter((tab) => tab.id !== menuTabId).map((tab) => tab.id), menuTabId),
|
||||
},
|
||||
{
|
||||
key: "close-right",
|
||||
label: t("tabBar.closeTabsToRight"),
|
||||
disabled: menuTabIndex >= tabs.length - 1,
|
||||
onSelect: () => {
|
||||
const rightTabIds = tabs.slice(menuTabIndex + 1).map((tab) => tab.id);
|
||||
const nextActiveTabId = resolvedActiveTabId && rightTabIds.includes(resolvedActiveTabId) ? menuTabId : undefined;
|
||||
closeTabsFromMenu(rightTabIds, nextActiveTabId);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="tabbar">
|
||||
<div className="tabbar__tabs">
|
||||
{tabs.map((tab) => {
|
||||
const displayTitle = tabDisplayTitle(tab);
|
||||
const fullTitle = tabFullTitle(tab);
|
||||
const mode = tabMode(tab);
|
||||
const stateTitle = [
|
||||
tab.running ? "Running" : "",
|
||||
mode === "yolo" ? "YOLO" : mode === "plan" ? "Plan" : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const annotatedTitle = stateTitle ? `${stateTitle} · ${fullTitle}` : fullTitle;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(node) => {
|
||||
if (node) {
|
||||
tabRefs.current.set(tab.id, node);
|
||||
} else {
|
||||
tabRefs.current.delete(tab.id);
|
||||
}
|
||||
}}
|
||||
draggable
|
||||
className={[
|
||||
"tabbar__tab",
|
||||
tab.id === resolvedActiveTabId ? "tabbar__tab--active" : "",
|
||||
mode === "yolo" ? "tabbar__tab--yolo" : "",
|
||||
draggingTabId === tab.id ? "tabbar__tab--dragging" : "",
|
||||
dropTarget?.id === tab.id ? `tabbar__tab--drop-${dropTarget.side}` : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
title={annotatedTitle}
|
||||
aria-label={annotatedTitle}
|
||||
style={projectAccentStyle(tab.projectColor)}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
onContextMenu={(event) => openTabMenu(event, tab.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
||||
openTabMenu(event, tab.id);
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => handleDragStart(event, tab.id)}
|
||||
onDragOver={(event) => handleDragOver(event, tab.id)}
|
||||
onDrop={(event) => handleDrop(event, tab.id)}
|
||||
onDragEnd={clearDragState}
|
||||
>
|
||||
{tab.tabType === "file" || tab.scope === "file" ? (
|
||||
<FileText size={12} className="tabbar__file-icon" />
|
||||
) : (
|
||||
<span
|
||||
className={[
|
||||
"tabbar__status",
|
||||
tab.running ? "tabbar__status--running" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
/>
|
||||
)}
|
||||
<span className="tabbar__tab-label">{displayTitle}</span>
|
||||
{mode === "yolo" && <span className="tabbar__mode-badge tabbar__mode-badge--yolo">yolo</span>}
|
||||
<span
|
||||
className="tabbar__tab-close"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClose(tab.id);
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Tooltip label={t("tabBar.newSession")}>
|
||||
<button className="tabbar__new" type="button" aria-label={t("tabBar.newSession")} onClick={onNewTab}>
|
||||
<Plus size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ContextMenu
|
||||
open={Boolean(menuTabId)}
|
||||
point={menuPoint}
|
||||
items={tabMenuItems}
|
||||
minWidth={170}
|
||||
ariaLabel={t("tabBar.tabActions")}
|
||||
onClose={closeTabMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, Circle, CircleDot, RefreshCw, X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Todo } from "../lib/tools";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -11,14 +11,21 @@ import { Tooltip } from "./Tooltip";
|
||||
// shows the current item so the footer stays compact during a long run. The ✕
|
||||
// dismisses it (onDismiss) when the user abandons the task; a fresh todo_write
|
||||
// brings it back.
|
||||
export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () => void }) {
|
||||
export function TodoPanel({ todos, stale, onDismiss }: { todos: Todo[]; stale?: boolean; onDismiss: () => void }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(true);
|
||||
if (todos.length === 0) return null;
|
||||
const currentRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const done = todos.filter((t) => t.status === "completed").length;
|
||||
const current = todos.find((t) => t.status === "in_progress");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
currentRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [open, current?.content, current?.activeForm]);
|
||||
|
||||
if (todos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="todobar">
|
||||
<div className="todobar__head">
|
||||
@@ -28,6 +35,12 @@ export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () =
|
||||
<span className="todobar__count">
|
||||
{done}/{todos.length}
|
||||
</span>
|
||||
{stale && (
|
||||
<span className="todobar__stale">
|
||||
<RefreshCw size={11} />
|
||||
{t("todo.stale")}
|
||||
</span>
|
||||
)}
|
||||
{!open && current && (
|
||||
<span className="todobar__current">{current.activeForm || current.content}</span>
|
||||
)}
|
||||
@@ -44,6 +57,7 @@ export function TodoPanel({ todos, onDismiss }: { todos: Todo[]; onDismiss: () =
|
||||
{todos.map((t, i) => (
|
||||
<li
|
||||
key={i}
|
||||
ref={t.status === "in_progress" ? currentRef : undefined}
|
||||
className={`todobar__item todobar__item--${t.status}${t.level ? " todobar__item--sub" : ""}`}
|
||||
>
|
||||
{t.status === "completed" ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import {
|
||||
Ban,
|
||||
Check,
|
||||
@@ -19,6 +19,7 @@ import { CodeViewer } from "./CodeViewer";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { diffsFor, subjectOf, summarize } from "../lib/tools";
|
||||
import { useShellExpand } from "../lib/shellExpand";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
@@ -36,6 +37,9 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
task: ListTree,
|
||||
};
|
||||
|
||||
/** Lines shown by default in a shell output block before the "show all" button. */
|
||||
const SHELL_PREVIEW_LINES = 10;
|
||||
|
||||
function pretty(json: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(json), null, 2);
|
||||
@@ -51,6 +55,14 @@ function StatusGlyph({ status }: { status: ToolItem["status"] }) {
|
||||
return <Check className="ico ico--ok" size={13} />;
|
||||
}
|
||||
|
||||
/** Returns the first n lines of text and the total line count. */
|
||||
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
|
||||
const lines = text.split("\n");
|
||||
const total = lines.length;
|
||||
if (total <= n) return { preview: text, total, hasMore: false };
|
||||
return { preview: lines.slice(0, n).join("\n"), total, hasMore: true };
|
||||
}
|
||||
|
||||
// ToolCard renders one tool call. `subcalls` are sub-agent calls nested under a
|
||||
// `task` card (their ParentID points at this call); they render inline, live, so
|
||||
// the sub-agent's work is visible as it happens.
|
||||
@@ -72,10 +84,20 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
|
||||
|
||||
// edit diffs are the point of the card, so they're shown inline; everything
|
||||
// else folds its args/output away by default. Nested children always show.
|
||||
// Shell commands default to open so the output is immediately visible.
|
||||
const hasBody = diffs.length === 0 && (!!item.args || !!item.output);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [open, setOpen] = useState(item.isShell && hasBody);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const expandable = hasBody;
|
||||
|
||||
// Register this shell card's toggle with the global ShellExpand context so
|
||||
// Ctrl/Cmd+B can expand/collapse the most recent shell output.
|
||||
const shellExpand = useShellExpand();
|
||||
useEffect(() => {
|
||||
if (!item.isShell || !shellExpand) return;
|
||||
return shellExpand.register(item.id, () => setOpen((v) => !v));
|
||||
}, [item.isShell, item.id, shellExpand]);
|
||||
|
||||
// Read-only "research" calls (read/grep/ls/glob/web_fetch) are quieted to a
|
||||
// slim, borderless, dim row so a long run of them doesn't bury the few calls
|
||||
// that matter — writers, bash, sub-agents, and anything that failed keep the
|
||||
@@ -83,6 +105,10 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
|
||||
const quiet =
|
||||
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
|
||||
// Shell output: split into preview + "show all" toggle.
|
||||
const shellOutput = item.isShell && item.output ? item.output : null;
|
||||
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
|
||||
|
||||
return (
|
||||
<div className={`tool tool--${item.status} ${quiet ? "tool--quiet" : ""}`}>
|
||||
<div
|
||||
@@ -119,7 +145,21 @@ export const ToolCard = memo(function ToolCard({ item, subcalls }: { item: ToolI
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasBody && open && (
|
||||
{/* Shell output: always visible (auto-open), with preview/show-all toggle */}
|
||||
{shellPreview && open && (
|
||||
<div className="tool__body">
|
||||
<CodeViewer value={showAll ? shellOutput! : shellPreview.preview} maxHeight={showAll ? 480 : 260} />
|
||||
{shellPreview.hasMore && !showAll && (
|
||||
<button className="tool__showall" onClick={() => setShowAll(true)}>
|
||||
{t("tool.showAllLines", { n: shellPreview.total })}
|
||||
</button>
|
||||
)}
|
||||
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-shell body: args + output, gated by open */}
|
||||
{!shellPreview && hasBody && open && (
|
||||
<div className="tool__body">
|
||||
{item.args && <CodeViewer value={pretty(item.args)} language="json" maxHeight={180} />}
|
||||
{item.output && (
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type CSSProperties, type MouseEvent as ReactMouseEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Item, LiveStream } from "../lib/useController";
|
||||
import type { CheckpointMeta } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { AssistantMessage, UserMessage } from "./Message";
|
||||
import { ToolCard } from "./ToolCard";
|
||||
import { Welcome } from "./Welcome";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
type QuestionAnchor = { id: string; text: string; turn: number };
|
||||
|
||||
const QUESTION_NAV_MIN_COUNT = 2;
|
||||
|
||||
function questionAnchorId(id: string): string {
|
||||
return `question-anchor-${id}`;
|
||||
}
|
||||
|
||||
function compactQuestionText(text: string): string {
|
||||
const cleaned = text.replace(/@\.reasonix\/attachments\/[^\s]+/g, "[image]").replace(/\s+/g, " ").trim();
|
||||
if (cleaned.length <= 80) return cleaned;
|
||||
return cleaned.slice(0, 80);
|
||||
}
|
||||
|
||||
function scrollVersion(items: Item[]): string {
|
||||
return items
|
||||
.map((it) => {
|
||||
switch (it.kind) {
|
||||
case "assistant":
|
||||
return `${it.id}:a:${it.text.length}:${it.reasoning.length}:${it.streaming ? 1 : 0}`;
|
||||
return `${it.id}:a:${it.text?.length ?? 0}:${it.reasoning?.length ?? 0}:${it.streaming ? 1 : 0}`;
|
||||
case "tool":
|
||||
return `${it.id}:t:${it.name}:${it.status}:${it.args.length}:${it.output?.length ?? 0}:${it.error?.length ?? 0}:${it.truncated ? 1 : 0}`;
|
||||
return `${it.id}:t:${it.name}:${it.status}:${it.args?.length ?? 0}:${it.output?.length ?? 0}:${it.error?.length ?? 0}:${it.truncated ? 1 : 0}`;
|
||||
default:
|
||||
return `${it.id}:${it.kind}`;
|
||||
}
|
||||
@@ -45,12 +59,20 @@ export function Transcript({
|
||||
footerHeight = 0,
|
||||
onPrompt,
|
||||
onRewind,
|
||||
checkpoints = [],
|
||||
actionPending = false,
|
||||
rewindDisabled = false,
|
||||
questionNavigator = true,
|
||||
}: {
|
||||
items: Item[];
|
||||
live?: LiveStream;
|
||||
footerHeight?: number;
|
||||
onPrompt: (text: string) => void;
|
||||
onRewind?: (turn: number, scope: string) => void;
|
||||
checkpoints?: CheckpointMeta[];
|
||||
actionPending?: boolean;
|
||||
rewindDisabled?: boolean;
|
||||
questionNavigator?: boolean;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// stick tracks whether the view is pinned to the bottom; once the user scrolls
|
||||
@@ -60,6 +82,18 @@ export function Transcript({
|
||||
const lastClientHeight = useRef<number | null>(null);
|
||||
const lastFooterHeight = useRef<number | null>(null);
|
||||
|
||||
const questions = useMemo<QuestionAnchor[]>(() => {
|
||||
const anchors: QuestionAnchor[] = [];
|
||||
let turn = 0;
|
||||
for (const it of items) {
|
||||
if (it.kind !== "user") continue;
|
||||
anchors.push({ id: it.id, text: compactQuestionText(it.text), turn });
|
||||
turn += 1;
|
||||
}
|
||||
return anchors;
|
||||
}, [items]);
|
||||
const showQuestionNav = questionNavigator && questions.length >= QUESTION_NAV_MIN_COUNT;
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (el) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
@@ -82,7 +116,7 @@ export function Transcript({
|
||||
el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [contentVersion, live?.text.length, live?.reasoning.length]);
|
||||
}, [contentVersion, live?.text?.length ?? 0, live?.reasoning?.length ?? 0]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
@@ -148,15 +182,37 @@ export function Transcript({
|
||||
|
||||
// Each user message's turn = its ordinal among user messages, so a rewind
|
||||
// targets the matching checkpoint.
|
||||
const userTurn = new Map<string, number>();
|
||||
let nt = 0;
|
||||
for (const it of items) {
|
||||
if (it.kind === "user") userTurn.set(it.id, nt++);
|
||||
}
|
||||
const userTurn = useMemo(() => new Map(questions.map((question) => [question.id, question.turn])), [questions]);
|
||||
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
|
||||
|
||||
const jumpToQuestion = (question: QuestionAnchor) => {
|
||||
const el = scrollRef.current;
|
||||
const node = document.getElementById(questionAnchorId(question.id));
|
||||
if (!el || !node) return;
|
||||
stick.current = false;
|
||||
if (resizeFrame.current !== null) {
|
||||
cancelAnimationFrame(resizeFrame.current);
|
||||
resizeFrame.current = null;
|
||||
}
|
||||
const scrollerRect = el.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const top = el.scrollTop + nodeRect.top - scrollerRect.top - 12;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: "smooth" });
|
||||
};
|
||||
|
||||
const empty = items.length === 0;
|
||||
|
||||
return (
|
||||
<div className="transcript" ref={scrollRef} onScroll={onScroll}>
|
||||
{items.length === 0 && <Welcome onPrompt={onPrompt} />}
|
||||
<div
|
||||
className={`transcript${empty ? " transcript--empty" : ""}`}
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{empty && <Welcome onPrompt={onPrompt} />}
|
||||
|
||||
{!empty && showQuestionNav && (
|
||||
<QuestionJumpBar questions={questions} onJump={jumpToQuestion} />
|
||||
)}
|
||||
|
||||
{items.map((it) => {
|
||||
switch (it.kind) {
|
||||
@@ -167,9 +223,13 @@ export function Transcript({
|
||||
key={it.id}
|
||||
text={it.text}
|
||||
turn={tn}
|
||||
open={tn != null && openTurn === tn}
|
||||
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
|
||||
onRewind={(turn, scope) => {
|
||||
anchorId={questionAnchorId(it.id)}
|
||||
open={tn != null && openTurn === tn}
|
||||
onToggle={() => setOpenTurn((cur) => (cur === tn ? null : (tn ?? null)))}
|
||||
checkpoint={tn != null ? checkpointsByTurn.get(tn) : undefined}
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
onRewind={(turn, scope) => {
|
||||
onRewind?.(turn, scope);
|
||||
setOpenTurn(null);
|
||||
}}
|
||||
@@ -207,6 +267,137 @@ export function Transcript({
|
||||
);
|
||||
}
|
||||
|
||||
function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
|
||||
const t = useT();
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const previewTop = useRef(0);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (questions.length === 0) return;
|
||||
setActive((cur) => {
|
||||
if (cur !== null && questions.some((question) => question.turn === cur)) return cur;
|
||||
return questions[questions.length - 1]?.turn ?? null;
|
||||
});
|
||||
}, [questions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active === null) return;
|
||||
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
|
||||
el?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
|
||||
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
|
||||
|
||||
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
|
||||
const el = barRef.current;
|
||||
if (!el) return null;
|
||||
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
|
||||
const barRect = el.getBoundingClientRect();
|
||||
let closest = -1;
|
||||
let closestDist = Infinity;
|
||||
let closestY = 0;
|
||||
markers.forEach((item, index) => {
|
||||
const rect = item.getBoundingClientRect();
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const dist = Math.abs(clientY - midY);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = index;
|
||||
closestY = midY - barRect.top;
|
||||
}
|
||||
});
|
||||
const question = questions[closest];
|
||||
if (!question) return null;
|
||||
return { question, previewY: closestY };
|
||||
};
|
||||
|
||||
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const closest = closestQuestionFromY(e.clientY);
|
||||
if (!closest) return;
|
||||
previewTop.current = closest.previewY;
|
||||
setHovered(closest.question.turn);
|
||||
setShowPreview(true);
|
||||
};
|
||||
|
||||
const scrollTo = (question: QuestionAnchor) => {
|
||||
setActive(question.turn);
|
||||
onJump(question);
|
||||
};
|
||||
|
||||
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const closest = closestQuestionFromY(e.clientY);
|
||||
if (!closest) return;
|
||||
e.preventDefault();
|
||||
previewTop.current = closest.previewY;
|
||||
setHovered(closest.question.turn);
|
||||
setShowPreview(true);
|
||||
scrollTo(closest.question);
|
||||
};
|
||||
|
||||
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
|
||||
e.preventDefault();
|
||||
scrollTo(question);
|
||||
};
|
||||
|
||||
const dotProps = (
|
||||
idx: number,
|
||||
turn: number,
|
||||
): { style: CSSProperties; "data-d"?: string } => {
|
||||
const isActive = active === turn;
|
||||
if (hoverIdx < 0) {
|
||||
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
|
||||
}
|
||||
const d = Math.abs(idx - hoverIdx);
|
||||
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
|
||||
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
|
||||
return {
|
||||
style: { width, transitionDelay: `${d * 20}ms`, background },
|
||||
"data-d": d <= 2 ? String(d) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="jump-bar"
|
||||
ref={barRef}
|
||||
aria-label={t("questionNav.label")}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => {
|
||||
setHovered(null);
|
||||
setShowPreview(false);
|
||||
}}
|
||||
>
|
||||
<div className="jump-scroll" onMouseDown={onRailMouseDown} onClick={onRailMouseDown}>
|
||||
{questions.map((question, index) => (
|
||||
<button
|
||||
className="jump-item"
|
||||
key={question.id}
|
||||
type="button"
|
||||
data-turn={question.turn}
|
||||
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
|
||||
onMouseDown={(e) => onItemMouseDown(e, question)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.detail === 0) scrollTo(question);
|
||||
}}
|
||||
>
|
||||
<span className="jump-dot" {...dotProps(index, question.turn)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showPreview && hoveredQuestion && (
|
||||
<div className="jump-preview" style={{ top: previewTop.current }} role="tooltip">
|
||||
<span className="jump-text">{hoveredQuestion.text}</span>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
type CompactionItem = Extract<Item, { kind: "compaction" }>;
|
||||
|
||||
// CompactionCard marks a context-compaction boundary in the transcript. While
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import logo from "../assets/logo.svg";
|
||||
import logoWordmark from "../assets/logo-wordmark.svg";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
// Welcome is the empty-state landing: brand, a one-liner, the input affordances
|
||||
@@ -10,8 +10,7 @@ export function Welcome({ onPrompt }: { onPrompt: (text: string) => void }) {
|
||||
const examples = [t("welcome.ex1"), t("welcome.ex2"), t("welcome.ex3")];
|
||||
return (
|
||||
<div className="welcome">
|
||||
<img src={logo} className="welcome__logo" alt="Reasonix" />
|
||||
<div className="welcome__title">Reasonix</div>
|
||||
<img src={logoWordmark} className="welcome__logo" alt="Reasonix" />
|
||||
<div className="welcome__tag">{t("welcome.tagline")}</div>
|
||||
|
||||
<div className="welcome__hints">
|
||||
|
||||
@@ -9,16 +9,14 @@ import type {
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
GitBranch,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Minus,
|
||||
Minimize2,
|
||||
PanelRightClose,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
X,
|
||||
@@ -29,16 +27,22 @@ import { loadLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import type { DirEntry, FilePreview, WorkspaceChangeView, WorkspaceChangesView } from "../lib/types";
|
||||
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
|
||||
import { FloatingMenu, FloatingMenuItems } from "./FloatingMenu";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { AnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 220;
|
||||
const WORKSPACE_TREE_DEFAULT_WIDTH = WORKSPACE_TREE_MIN_WIDTH;
|
||||
const WORKSPACE_TREE_MAX_WIDTH = 420;
|
||||
const WORKSPACE_PREVIEW_MIN_WIDTH = 420;
|
||||
const WORKSPACE_TREE_MIN_WIDTH = 208;
|
||||
const WORKSPACE_TREE_DEFAULT_WIDTH = 240;
|
||||
const WORKSPACE_TREE_MAX_WIDTH = 340;
|
||||
const WORKSPACE_PREVIEW_MIN_WIDTH = 360;
|
||||
const WORKSPACE_PREVIEW_TARGET_WIDTH = 480;
|
||||
const WORKSPACE_DUAL_PANEL_MIN_WIDTH = WORKSPACE_TREE_MIN_WIDTH + WORKSPACE_PREVIEW_MIN_WIDTH;
|
||||
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
|
||||
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 92;
|
||||
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 48;
|
||||
const WORKSPACE_MAX_PREVIEW_TABS = 5;
|
||||
|
||||
function clampWorkspaceTreeWidth(width: number, panelWidth?: number): number {
|
||||
const maxForPanel =
|
||||
@@ -155,7 +159,10 @@ export function WorkspacePanel({
|
||||
onToggleMaximized,
|
||||
onPreviewModeChange,
|
||||
onAddToChat,
|
||||
changesRefreshKey,
|
||||
onRequestPanelWidth,
|
||||
refreshKey,
|
||||
initialViewMode = "files",
|
||||
showViewTabs = true,
|
||||
}: {
|
||||
open: boolean;
|
||||
cwd?: string;
|
||||
@@ -165,7 +172,10 @@ export function WorkspacePanel({
|
||||
onToggleMaximized: () => void;
|
||||
onPreviewModeChange?: (active: boolean) => void;
|
||||
onAddToChat?: (text: string) => void;
|
||||
changesRefreshKey?: number;
|
||||
onRequestPanelWidth?: (width: number) => void;
|
||||
refreshKey?: number;
|
||||
initialViewMode?: "files" | "changed";
|
||||
showViewTabs?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
@@ -177,16 +187,24 @@ export function WorkspacePanel({
|
||||
const [openTabs, setOpenTabs] = useState<string[]>([]);
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">("files");
|
||||
const [viewMode, setViewMode] = useState<"files" | "changed">(initialViewMode);
|
||||
const [changes, setChanges] = useState<WorkspaceChangesView | null>(null);
|
||||
const [loadingChanges, setLoadingChanges] = useState(false);
|
||||
const [selectionMenu, setSelectionMenu] = useState<{ x: number; y: number; text: string; path: string } | null>(null);
|
||||
const [treeMenu, setTreeMenu] = useState<{ x: number; y: number; path: string; isDir: boolean } | null>(null);
|
||||
const [treeBlankMenuPoint, setTreeBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const changesRequestRef = useRef(0);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [treeVisible, setTreeVisible] = useState(true);
|
||||
const [treeWidth, setTreeWidth] = useState(loadWorkspaceTreeWidth);
|
||||
const [treeResizing, setTreeResizing] = useState(false);
|
||||
const [recentOpen, setRecentOpen] = useState(false);
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
|
||||
useEffect(() => {
|
||||
openDirsRef.current = openDirs;
|
||||
}, [openDirs]);
|
||||
|
||||
const loadDir = useCallback(async (dir: string) => {
|
||||
const entries = await app.ListDir(dir).catch(() => []);
|
||||
@@ -211,16 +229,17 @@ export function WorkspacePanel({
|
||||
|
||||
const selectFile = useCallback(
|
||||
(path: string) => {
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
setSelectedPath(path);
|
||||
setFilter("");
|
||||
setOpenTabs((tabs) => (tabs.includes(path) ? tabs : [...tabs, path]));
|
||||
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
const dirs = parentDirs(path);
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
},
|
||||
[entriesByDir, loadDir],
|
||||
[entriesByDir, loadDir, onRequestPanelWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -235,13 +254,26 @@ export function WorkspacePanel({
|
||||
setTreeMenu(null);
|
||||
setFilter("");
|
||||
setTreeVisible(true);
|
||||
setViewMode(initialViewMode);
|
||||
void loadDir("");
|
||||
}, [cwd, loadDir, open]);
|
||||
}, [cwd, initialViewMode, loadDir, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setViewMode(initialViewMode);
|
||||
if (initialViewMode === "changed") void loadChanges();
|
||||
}, [initialViewMode, loadChanges, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void loadChanges();
|
||||
}, [changesRefreshKey, cwd, loadChanges, open]);
|
||||
}, [cwd, loadChanges, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !refreshKey) return;
|
||||
void loadChanges();
|
||||
openDirsRef.current.forEach((dir) => void loadDir(dir));
|
||||
}, [loadChanges, loadDir, open, refreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectionMenu && !treeMenu) return;
|
||||
@@ -262,6 +294,19 @@ export function WorkspacePanel({
|
||||
};
|
||||
}, [selectionMenu, treeMenu]);
|
||||
|
||||
const refreshWorkspaceList = useCallback(() => {
|
||||
setTreeBlankMenuPoint(null);
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
if (viewMode === "changed") {
|
||||
void loadChanges();
|
||||
return;
|
||||
}
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadChanges, loadDir, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
let live = true;
|
||||
@@ -312,16 +357,6 @@ export function WorkspacePanel({
|
||||
[entriesByDir, loadDir],
|
||||
);
|
||||
|
||||
const openPickerTab = () => {
|
||||
setSelectedPath(null);
|
||||
setPreview(null);
|
||||
setFilter("");
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setTreeVisible(true);
|
||||
requestAnimationFrame(() => filterRef.current?.focus());
|
||||
};
|
||||
|
||||
const closeTab = (path: string) => {
|
||||
setOpenTabs((tabs) => {
|
||||
const next = tabs.filter((tab) => tab !== path);
|
||||
@@ -334,6 +369,7 @@ export function WorkspacePanel({
|
||||
}
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setRecentOpen(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -341,6 +377,9 @@ export function WorkspacePanel({
|
||||
|
||||
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
|
||||
const pathParts = selectedPath?.split("/").filter(Boolean) ?? [];
|
||||
const currentFileName = selectedPath ? basename(selectedPath) : t("workspace.noFile");
|
||||
const currentFileDir = selectedPath ? parentPath(selectedPath) : "";
|
||||
const recentFiles = useMemo(() => [...openTabs].reverse(), [openTabs]);
|
||||
const flattened = useMemo(() => {
|
||||
const rows: { path: string; entry: DirEntry }[] = [];
|
||||
for (const [dir, entries] of Object.entries(entriesByDir)) {
|
||||
@@ -361,14 +400,25 @@ export function WorkspacePanel({
|
||||
if (!q) return rows;
|
||||
return rows.filter((row) => `${row.path} ${row.oldPath ?? ""} ${row.gitStatus ?? ""}`.toLowerCase().includes(q));
|
||||
}, [changes?.files, filter]);
|
||||
const searchPlaceholder = viewMode === "changed" ? t("workspace.filterChanges") : t("workspace.filter");
|
||||
|
||||
const effectiveTreeWidth = useMemo(() => clampWorkspaceTreeWidth(treeWidth, panelWidth), [panelWidth, treeWidth]);
|
||||
const previewVisible = openTabs.length > 0 || selectedPath !== null;
|
||||
const selectedFileVisible = selectedPath !== null;
|
||||
const compactTreeSplit =
|
||||
treeVisible && selectedFileVisible && panelWidth !== undefined && panelWidth < WORKSPACE_DUAL_PANEL_MIN_WIDTH;
|
||||
const actualTreeVisible = treeVisible;
|
||||
const previewModeActive = open && previewVisible;
|
||||
const embeddedDockMode = !showViewTabs;
|
||||
const showFileTools = showViewTabs || previewVisible;
|
||||
|
||||
const panelStyle = useMemo(
|
||||
() => ({ "--workspace-tree-width": `${effectiveTreeWidth}px` }) as CSSProperties,
|
||||
[effectiveTreeWidth],
|
||||
() =>
|
||||
({
|
||||
"--workspace-tree-width": `${effectiveTreeWidth}px`,
|
||||
"--workspace-preview-min-width": compactTreeSplit ? "0px" : `${WORKSPACE_PREVIEW_MIN_WIDTH}px`,
|
||||
}) as CSSProperties,
|
||||
[compactTreeSplit, effectiveTreeWidth],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -405,7 +455,7 @@ export function WorkspacePanel({
|
||||
setTreeResizing(true);
|
||||
let nextWidth = effectiveTreeWidth;
|
||||
const onMove = (moveEvent: PointerEvent) => {
|
||||
nextWidth = clampWorkspaceTreeWidth(rect.right - moveEvent.clientX, rect.width);
|
||||
nextWidth = clampWorkspaceTreeWidth(moveEvent.clientX - rect.left, rect.width);
|
||||
setTreeWidth(nextWidth);
|
||||
};
|
||||
const onDone = () => {
|
||||
@@ -431,7 +481,7 @@ export function WorkspacePanel({
|
||||
(event: KeyboardEvent<HTMLButtonElement>) => {
|
||||
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowLeft" ? 16 : -16));
|
||||
setSavedTreeWidth(effectiveTreeWidth + (event.key === "ArrowRight" ? 16 : -16));
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setSavedTreeWidth(WORKSPACE_TREE_MIN_WIDTH);
|
||||
@@ -474,10 +524,21 @@ export function WorkspacePanel({
|
||||
const openTreeMenu = (event: ReactMouseEvent<HTMLElement>, path: string, isDir: boolean) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setTreeBlankMenuPoint(null);
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu({ x: event.clientX, y: event.clientY, path, isDir });
|
||||
};
|
||||
|
||||
const openTreeBlankMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest(".workspace-tree__row,.workspace-change,button,input,textarea,select")) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setSelectionMenu(null);
|
||||
setTreeMenu(null);
|
||||
setTreeBlankMenuPoint(contextMenuPointFromEvent(event));
|
||||
};
|
||||
|
||||
const startTreeDrag = (event: ReactDragEvent<HTMLElement>, path: string, isDir: boolean) => {
|
||||
const ref = formatWorkspaceReference(path, isDir);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
@@ -550,32 +611,31 @@ export function WorkspacePanel({
|
||||
const isOpen = openDirs.has(path);
|
||||
const active = selectedPath === path;
|
||||
const row = (
|
||||
<Tooltip key={path} label={path} fill>
|
||||
<button
|
||||
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
isOpen ? (
|
||||
<ChevronDown size={13} className="workspace-tree__chev" />
|
||||
) : (
|
||||
<ChevronRight size={13} className="workspace-tree__chev" />
|
||||
)
|
||||
<button
|
||||
key={path}
|
||||
className={`workspace-tree__row${active ? " workspace-tree__row--active" : ""}`}
|
||||
draggable
|
||||
onDragStart={(event) => startTreeDrag(event, path, entry.isDir)}
|
||||
onClick={() => (entry.isDir ? toggleDir(path) : selectFile(path))}
|
||||
onContextMenu={(event) => openTreeMenu(event, path, entry.isDir)}
|
||||
style={{ paddingLeft: 8 + depth * 14 }}
|
||||
>
|
||||
{entry.isDir ? (
|
||||
isOpen ? (
|
||||
<ChevronDown size={13} className="workspace-tree__chev" />
|
||||
) : (
|
||||
<span className="workspace-tree__chev" />
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<FileText size={14} className="workspace-tree__icon" />
|
||||
)}
|
||||
<span className="workspace-tree__name">{entry.name}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<ChevronRight size={13} className="workspace-tree__chev" />
|
||||
)
|
||||
) : (
|
||||
<span className="workspace-tree__chev" />
|
||||
)}
|
||||
{entry.isDir ? (
|
||||
<Folder size={14} className="workspace-tree__icon workspace-tree__icon--dir" />
|
||||
) : (
|
||||
<FileText size={14} className="workspace-tree__icon" />
|
||||
)}
|
||||
<span className="workspace-tree__name">{entry.name}</span>
|
||||
</button>
|
||||
);
|
||||
if (!entry.isDir || !isOpen) return [row];
|
||||
return [row, ...renderRows(path, depth + 1)];
|
||||
@@ -583,50 +643,42 @@ export function WorkspacePanel({
|
||||
};
|
||||
|
||||
const isMarkdown = selectedPath?.toLowerCase().endsWith(".md") ?? false;
|
||||
const treeBlankMenuItems: ContextMenuItem[] = [
|
||||
{
|
||||
key: "refresh-tree",
|
||||
icon: <RefreshCw size={13} />,
|
||||
label: t(viewMode === "changed" ? "workspace.refreshChanges" : "workspace.refreshTree"),
|
||||
onSelect: refreshWorkspaceList,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
className={`workspace-panel${treeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
|
||||
className={`workspace-panel${embeddedDockMode ? " workspace-panel--embedded" : ""}${previewVisible && actualTreeVisible ? " workspace-panel--split-preview" : ""}${compactTreeSplit ? " workspace-panel--compact-split" : ""}${actualTreeVisible ? "" : " workspace-panel--tree-hidden"}${previewVisible ? "" : " workspace-panel--preview-hidden"}${treeResizing ? " workspace-panel--tree-resizing" : ""}`}
|
||||
aria-label={t("workspace.title")}
|
||||
style={panelStyle}
|
||||
>
|
||||
{previewVisible && <section className="workspace-preview">
|
||||
<header className="workspace-preview__head">
|
||||
<div className="workspace-tabs">
|
||||
{openTabs.map((tab) => (
|
||||
<Tooltip key={tab} label={tab}>
|
||||
<button
|
||||
className={`workspace-tab${selectedPath === tab ? " workspace-tab--active" : ""}`}
|
||||
onClick={() => setSelectedPath(tab)}
|
||||
>
|
||||
<FileText size={14} className="workspace-tab__icon" />
|
||||
<span className="workspace-tab__name">{basename(tab)}</span>
|
||||
<span
|
||||
className="workspace-tab__close"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t("workspace.closeTab")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeTab(tab);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeTab(tab);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
</button>
|
||||
<div className="workspace-current-file" aria-label={t("workspace.currentFile")}>
|
||||
<FileText size={15} className="workspace-current-file__icon" />
|
||||
<div className="workspace-current-file__text">
|
||||
<Tooltip label={selectedPath ?? undefined}>
|
||||
<span className="workspace-current-file__name">{currentFileName}</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip label={t("workspace.newTab")}>
|
||||
<button className="workspace-tab workspace-tab--new" onClick={openPickerTab}>
|
||||
<Plus size={14} />
|
||||
{currentFileDir && <span className="workspace-current-file__path">{currentFileDir}</span>}
|
||||
</div>
|
||||
<Tooltip label={t("workspace.recentFiles")}>
|
||||
<button
|
||||
ref={recentAnchorRef}
|
||||
className={`workspace-current-file__recent${recentOpen ? " workspace-current-file__recent--open" : ""}`}
|
||||
type="button"
|
||||
aria-label={t("workspace.recentFiles")}
|
||||
aria-expanded={recentOpen}
|
||||
onClick={() => setRecentOpen((open) => !open)}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -637,20 +689,44 @@ export function WorkspacePanel({
|
||||
{maximized ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("workspace.minimize")}>
|
||||
<button className="workspace-iconbtn" onClick={onClose}>
|
||||
<Minus size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={treeVisible ? t("workspace.hideTree") : t("workspace.showTree")}>
|
||||
<button
|
||||
className="workspace-iconbtn workspace-iconbtn--on"
|
||||
onClick={() => setTreeVisible((value) => !value)}
|
||||
>
|
||||
{treeVisible ? <PanelRightClose size={15} /> : <Columns2 size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{selectedPath && (
|
||||
<Tooltip label={t("workspace.closePreview")}>
|
||||
<button className="workspace-iconbtn" onClick={() => closeTab(selectedPath)}>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<AnchoredPopover
|
||||
open={recentOpen}
|
||||
anchorRef={recentAnchorRef}
|
||||
onClose={() => setRecentOpen(false)}
|
||||
className="workspace-recent-menu"
|
||||
align="start"
|
||||
offset={6}
|
||||
placement="bottom"
|
||||
>
|
||||
<div className="workspace-recent-menu__title">{t("workspace.recentFiles")}</div>
|
||||
<div className="workspace-recent-menu__list">
|
||||
{recentFiles.map((path) => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
className={`workspace-recent-menu__item${path === selectedPath ? " workspace-recent-menu__item--active" : ""}`}
|
||||
onClick={() => {
|
||||
setSelectedPath(path);
|
||||
setRecentOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileText size={14} />
|
||||
<span>
|
||||
<span className="workspace-recent-menu__name">{basename(path)}</span>
|
||||
<span className="workspace-recent-menu__path">{parentPath(path)}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
</header>
|
||||
|
||||
<div className="workspace-preview__meta">
|
||||
@@ -660,6 +736,7 @@ export function WorkspacePanel({
|
||||
onClick={() => {
|
||||
setFilter("");
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
|
||||
}}
|
||||
>
|
||||
@@ -678,6 +755,7 @@ export function WorkspacePanel({
|
||||
onClick={() => {
|
||||
if (isLast) return;
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
setFilter("");
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...breadcrumbDirs, dir]));
|
||||
void loadDir(dir);
|
||||
@@ -727,7 +805,25 @@ export function WorkspacePanel({
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{treeVisible && previewVisible && (
|
||||
{previewVisible && !actualTreeVisible && (
|
||||
<section className="workspace-tree-rail" aria-label={t("workspace.showTree")}>
|
||||
<Tooltip label={t("workspace.showTree")} side="right">
|
||||
<button
|
||||
className="workspace-tree-reveal workspace-iconbtn workspace-iconbtn--on"
|
||||
type="button"
|
||||
aria-label={t("workspace.showTree")}
|
||||
onClick={() => {
|
||||
setTreeVisible(true);
|
||||
onRequestPanelWidth?.(WORKSPACE_DUAL_PANEL_TARGET_WIDTH);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{actualTreeVisible && previewVisible && (
|
||||
<button
|
||||
className="workspace-tree-resizer"
|
||||
type="button"
|
||||
@@ -744,48 +840,56 @@ export function WorkspacePanel({
|
||||
)}
|
||||
|
||||
<section className="workspace-files">
|
||||
<div className="workspace-files__tools">
|
||||
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
|
||||
<button
|
||||
className="workspace-iconbtn workspace-iconbtn--on"
|
||||
onClick={hideTreeOrClosePanel}
|
||||
>
|
||||
<PanelRightClose size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
|
||||
<button
|
||||
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
|
||||
onClick={() => setViewMode("files")}
|
||||
>
|
||||
{t("workspace.filesTab")}
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
|
||||
onClick={() => {
|
||||
setViewMode("changed");
|
||||
void loadChanges();
|
||||
}}
|
||||
>
|
||||
<GitBranch size={13} />
|
||||
{t("workspace.changedTab")}
|
||||
</button>
|
||||
{showFileTools && (
|
||||
<div className={`workspace-files__tools${embeddedDockMode ? " workspace-files__tools--embedded" : ""}`}>
|
||||
<Tooltip label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}>
|
||||
<button
|
||||
className="workspace-iconbtn workspace-iconbtn--on"
|
||||
type="button"
|
||||
aria-label={previewVisible ? t("workspace.hideTree") : t("workspace.close")}
|
||||
onClick={hideTreeOrClosePanel}
|
||||
>
|
||||
{previewVisible ? <FolderX size={15} /> : <X size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showViewTabs && (
|
||||
<div className="workspace-files__tabs" role="tablist" aria-label={t("workspace.viewMode")}>
|
||||
<button
|
||||
className={viewMode === "files" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
|
||||
onClick={() => setViewMode("files")}
|
||||
>
|
||||
{t("workspace.filesTab")}
|
||||
</button>
|
||||
<button
|
||||
className={viewMode === "changed" ? "workspace-files__tab workspace-files__tab--active" : "workspace-files__tab"}
|
||||
onClick={() => {
|
||||
setViewMode("changed");
|
||||
void loadChanges();
|
||||
}}
|
||||
>
|
||||
<GitBranch size={13} />
|
||||
{t("workspace.changedTab")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showViewTabs && (
|
||||
<Tooltip label={t("workspace.refreshChanges")}>
|
||||
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Tooltip label={t("workspace.refreshChanges")}>
|
||||
<button className="workspace-iconbtn" onClick={() => void loadChanges()}>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="workspace-search">
|
||||
<Search size={14} />
|
||||
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={t("workspace.filter")} />
|
||||
<input ref={filterRef} value={filter} onChange={(e) => setFilter(e.target.value)} placeholder={searchPlaceholder} />
|
||||
</div>
|
||||
{viewMode === "changed" && changes && !changes.gitAvailable && changes.gitErr && (
|
||||
<div className="workspace-note workspace-note--compact">{t("workspace.gitUnavailable")}</div>
|
||||
)}
|
||||
<div className="workspace-tree">
|
||||
<div className="workspace-tree" onContextMenu={openTreeBlankMenu}>
|
||||
{viewMode === "changed"
|
||||
? renderChangedRows()
|
||||
: flattened
|
||||
@@ -842,6 +946,14 @@ export function WorkspacePanel({
|
||||
/>
|
||||
</FloatingMenu>
|
||||
)}
|
||||
<ContextMenu
|
||||
open={Boolean(treeBlankMenuPoint)}
|
||||
point={treeBlankMenuPoint}
|
||||
items={treeBlankMenuItems}
|
||||
minWidth={150}
|
||||
ariaLabel={t("workspace.treeMenu")}
|
||||
onClose={() => setTreeBlankMenuPoint(null)}
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function asArray<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
+766
-140
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,8 @@
|
||||
// Last-resort crash surface: a React render error with no boundary unmounts the
|
||||
// whole tree (blank window), and global errors/rejections leave no trace either.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
function paint(text: string) {
|
||||
let host = document.getElementById("crash-overlay");
|
||||
if (!host) {
|
||||
@@ -10,13 +12,13 @@ function paint(text: string) {
|
||||
}
|
||||
const title = document.createElement("div");
|
||||
title.className = "crash-overlay__title";
|
||||
title.textContent = "Reasonix hit an error — screenshot this and send it over";
|
||||
title.textContent = t("crash.title");
|
||||
const body = document.createElement("pre");
|
||||
body.className = "crash-overlay__body";
|
||||
body.textContent = text;
|
||||
const copy = document.createElement("button");
|
||||
copy.className = "crash-overlay__copy";
|
||||
copy.textContent = "Copy";
|
||||
copy.textContent = t("crash.copy");
|
||||
copy.onclick = () => void navigator.clipboard?.writeText(text);
|
||||
host.replaceChildren(title, body, copy);
|
||||
}
|
||||
|
||||
@@ -43,15 +43,77 @@ export function resolveLang(lang?: string): string {
|
||||
return hljs.getLanguage(resolved) ? resolved : "";
|
||||
}
|
||||
|
||||
// LRU cache for highlighted output. The same code block can re-render many
|
||||
// times (Re-renders due to streaming updates that don't change this block,
|
||||
// React's StrictMode double-invoke in dev, hover-reveals of the toolbar's
|
||||
// child elements). highlight.highlight() is a real lexer walk that shows
|
||||
// up in the profile for large blocks; a 200-entry LRU keyed on the
|
||||
// resolved language plus a fast hash of the code keeps the steady-state
|
||||
// cost at a Map.get(). The Map is a plain LRU, not a WeakMap, so a large
|
||||
// (non-streaming) transcript will eventually evict; the size of 200 is
|
||||
// chosen to cover the visible viewport plus a small overshoot — most
|
||||
// transcripts re-render the same ~30 visible blocks.
|
||||
const HL_CACHE_MAX = 200;
|
||||
|
||||
// djb2 hash for the cache key. We only use the hash inside the key
|
||||
// (Map<number, string>), not as a security primitive; collisions are fine
|
||||
// because we ALSO store the original code alongside the entry and verify
|
||||
// before serving the cached value. The hash is what makes the key
|
||||
// constant-time to compare for Map.get(); comparing the full source
|
||||
// string would be O(n) on every render and dwarf the savings.
|
||||
function hashCode(s: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
||||
return h;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
code: string;
|
||||
html: string;
|
||||
}
|
||||
const hlCache = new Map<number, CacheEntry>();
|
||||
|
||||
function cacheGet(code: string, lang: string): string | null {
|
||||
const key = hashCode(lang + "\0" + code);
|
||||
const e = hlCache.get(key);
|
||||
if (!e) return null;
|
||||
// Defend against the (rare) hash collision: the stored code must match
|
||||
// the queried code exactly. We move the entry to the end of the Map to
|
||||
// mark it most-recently-used.
|
||||
if (e.code !== code) return null;
|
||||
hlCache.delete(key);
|
||||
hlCache.set(key, e);
|
||||
return e.html;
|
||||
}
|
||||
|
||||
function cachePut(code: string, lang: string, html: string): void {
|
||||
const key = hashCode(lang + "\0" + code);
|
||||
if (hlCache.has(key)) hlCache.delete(key); // refresh
|
||||
hlCache.set(key, { code, html });
|
||||
while (hlCache.size > HL_CACHE_MAX) {
|
||||
// Map iteration is insertion-order; the first key is the oldest.
|
||||
const oldest = hlCache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
hlCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// highlightToHtml returns highlighted HTML (token <span>s) for the given code, or
|
||||
// escaped plain text when the language is unknown. ignoreIllegals so partial /
|
||||
// streaming snippets never throw.
|
||||
// streaming snippets never throw. The LRU cache shaves the bulk of the work
|
||||
// when a transcript re-renders the same blocks (most common: a streaming
|
||||
// update changes the *next* block, not this one).
|
||||
export function highlightToHtml(code: string, lang?: string): string {
|
||||
const resolved = resolveLang(lang);
|
||||
if (!resolved) return escapeHtml(code);
|
||||
const cached = cacheGet(code, resolved);
|
||||
if (cached !== null) return cached;
|
||||
let html: string;
|
||||
try {
|
||||
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
|
||||
html = hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return escapeHtml(code);
|
||||
}
|
||||
cachePut(code, resolved, html);
|
||||
return html;
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
// non-React code (lib/tools.ts) translate too; it stays fresh because the provider
|
||||
// updates it on every render.
|
||||
//
|
||||
// Desktop UI language is intentionally frontend-only. The CLI/kernel may still
|
||||
// have its own `language` config for prompts and terminal text, but switching the
|
||||
// desktop setting must not rewrite config or rebuild the model controller.
|
||||
// Desktop UI language is intentionally separate from the CLI/kernel `language`
|
||||
// config for prompts and terminal text. The desktop preference is persisted in
|
||||
// the user-level [desktop] config; localStorage is only read once for legacy
|
||||
// migration from older desktop builds.
|
||||
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -15,6 +16,7 @@ import { en, type DictKey } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
|
||||
export type Locale = "en" | "zh";
|
||||
export type { DictKey };
|
||||
// LangPref is the stored preference: "" means auto-detect from the OS.
|
||||
export type LangPref = "" | "en" | "zh";
|
||||
|
||||
@@ -48,16 +50,23 @@ export function detectLocale(pref: LangPref): Locale {
|
||||
}
|
||||
|
||||
function readPref(): LangPref {
|
||||
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function normalizeLangPref(v: unknown): LangPref {
|
||||
return v === "en" || v === "zh" ? v : "";
|
||||
}
|
||||
|
||||
function writePref(pref: LangPref): void {
|
||||
export function readLegacyLangPref(): LangPref {
|
||||
const v = typeof localStorage !== "undefined" ? localStorage.getItem(STORAGE_KEY) : null;
|
||||
return normalizeLangPref(v);
|
||||
}
|
||||
|
||||
export function clearLegacyLangPref(): void {
|
||||
try {
|
||||
if (pref) localStorage.setItem(STORAGE_KEY, pref);
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
/* private mode / no storage — the in-memory state still applies this session */
|
||||
/* private mode / no storage */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +88,7 @@ export function getLocale(): Locale {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
|
||||
export type Translator = (key: DictKey, vars?: Record<string, string | number>) => string;
|
||||
|
||||
interface I18nValue {
|
||||
locale: Locale;
|
||||
@@ -95,10 +104,9 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
const locale = detectLocale(pref);
|
||||
currentLocale = locale; // keep the mirror fresh for non-React callers
|
||||
|
||||
// setPref updates only the live UI and the browser cache.
|
||||
// setPref updates only the live UI; persistence is handled by desktop config.
|
||||
const setPref = useCallback((next: LangPref) => {
|
||||
writePref(next);
|
||||
setPrefState(next);
|
||||
setPrefState(normalizeLangPref(next));
|
||||
}, []);
|
||||
|
||||
const tt = useCallback<Translator>((key, vars) => translate(detectLocale(pref), key, vars), [pref]);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type LayoutSizeKey =
|
||||
| "sidebarWidth"
|
||||
| "workspacePanelWidth"
|
||||
| "rightDockWidth"
|
||||
| "rightDockTreeWidth"
|
||||
| "rightDockPreviewWidth"
|
||||
| "workspaceFileTreePanelWidth"
|
||||
| "workspaceTreeWidth"
|
||||
| "composerHeight"
|
||||
@@ -15,7 +17,9 @@ const STORAGE_KEY = "reasonix.layoutPreferences.v1";
|
||||
|
||||
const LEGACY_SIZE_KEYS: Record<LayoutSizeKey, string[]> = {
|
||||
sidebarWidth: ["reasonix.sidebar.width"],
|
||||
workspacePanelWidth: ["reasonix.workspacePanel.width"],
|
||||
rightDockWidth: [],
|
||||
rightDockTreeWidth: [],
|
||||
rightDockPreviewWidth: [],
|
||||
workspaceFileTreePanelWidth: [],
|
||||
workspaceTreeWidth: ["reasonix.workspaceTree.width"],
|
||||
composerHeight: ["reasonix.composerHeight"],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ProjectColorKey = "" | "red" | "orange" | "amber" | "green" | "teal" | "blue" | "purple" | "pink";
|
||||
|
||||
export interface ProjectColorOption {
|
||||
key: ProjectColorKey;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const PROJECT_COLOR_OPTIONS: ProjectColorOption[] = [
|
||||
{ key: "" },
|
||||
{ key: "red", value: "#e5534b" },
|
||||
{ key: "orange", value: "#d66e4b" },
|
||||
{ key: "amber", value: "#d59a2f" },
|
||||
{ key: "green", value: "#4f9f64" },
|
||||
{ key: "teal", value: "#1f9d93" },
|
||||
{ key: "blue", value: "#3d7be0" },
|
||||
{ key: "purple", value: "#8b6de8" },
|
||||
{ key: "pink", value: "#cf6ca5" },
|
||||
];
|
||||
|
||||
const colorValues = new Map(PROJECT_COLOR_OPTIONS.map((option) => [option.key, option.value]));
|
||||
|
||||
export function projectColorValue(key?: string): string | undefined {
|
||||
return colorValues.get((key || "") as ProjectColorKey);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
|
||||
|
||||
// Shell-expand coordination: ToolCards register their toggle callbacks, and
|
||||
// Cmd+B (from App) calls the most recent one.
|
||||
|
||||
type ToggleFn = () => void;
|
||||
|
||||
interface ShellExpandCtx {
|
||||
register: (id: string, toggle: ToggleFn) => void;
|
||||
toggleLast: () => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ShellExpandCtx | null>(null);
|
||||
|
||||
export function ShellExpandProvider({ children }: { children: ReactNode }) {
|
||||
const mapRef = useRef(new Map<string, ToggleFn>());
|
||||
const orderRef: { current: string[] } = useRef<string[]>([]);
|
||||
|
||||
const register = useCallback((id: string, toggle: ToggleFn) => {
|
||||
mapRef.current.set(id, toggle);
|
||||
if (!orderRef.current.includes(id)) {
|
||||
orderRef.current.push(id);
|
||||
}
|
||||
return () => {
|
||||
mapRef.current.delete(id);
|
||||
orderRef.current = orderRef.current.filter((x) => x !== id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleLast = useCallback(() => {
|
||||
const ids = orderRef.current;
|
||||
if (ids.length === 0) return;
|
||||
const fn = mapRef.current.get(ids[ids.length - 1]);
|
||||
fn?.();
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ register, toggleLast }), [register, toggleLast]);
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useShellExpand() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const TEXT_SIZES = ["small", "default", "large", "xlarge"] as const;
|
||||
|
||||
export type TextSize = (typeof TEXT_SIZES)[number];
|
||||
|
||||
export const DEFAULT_TEXT_SIZE: TextSize = "default";
|
||||
|
||||
const TEXT_SIZE_KEY = "reasonix-text-size";
|
||||
|
||||
export function isTextSize(value: unknown): value is TextSize {
|
||||
return typeof value === "string" && (TEXT_SIZES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function getTextSize(): TextSize {
|
||||
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(TEXT_SIZE_KEY) : null;
|
||||
return isTextSize(stored) ? stored : DEFAULT_TEXT_SIZE;
|
||||
}
|
||||
|
||||
export function applyTextSize(size: TextSize): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (size === DEFAULT_TEXT_SIZE) root.removeAttribute("data-text-size");
|
||||
else root.setAttribute("data-text-size", size);
|
||||
try {
|
||||
localStorage.setItem(TEXT_SIZE_KEY, size);
|
||||
} catch {
|
||||
/* private mode / no storage - the in-DOM attribute still applies */
|
||||
}
|
||||
}
|
||||
|
||||
export function initTextSize(): void {
|
||||
applyTextSize(getTextSize());
|
||||
}
|
||||
@@ -1,6 +1,16 @@
|
||||
// theme.ts manages the appearance override. The stylesheet follows the OS via
|
||||
// prefers-color-scheme unless data-theme forces "dark" or "light". A separate
|
||||
// data-theme-style attribute changes only accent tokens.
|
||||
//
|
||||
// When running inside the Wails shell, applyTheme also syncs the native window
|
||||
// theme (title bar, traffic lights, etc.) so the OS chrome matches the webview.
|
||||
|
||||
import {
|
||||
WindowSetDarkTheme,
|
||||
WindowSetLightTheme,
|
||||
WindowSetSystemDefaultTheme,
|
||||
WindowSetBackgroundColour,
|
||||
} from "../../wailsjs/runtime/runtime";
|
||||
|
||||
export type Theme = "auto" | "light" | "dark";
|
||||
export type ResolvedTheme = Exclude<Theme, "auto">;
|
||||
@@ -31,17 +41,22 @@ export const THEME_STYLE_THEME: Record<ThemeStyle, ResolvedTheme> = {
|
||||
|
||||
const DEFAULT_THEME_STYLE: Record<ResolvedTheme, ThemeStyle> = {
|
||||
dark: "graphite",
|
||||
light: "sandstone",
|
||||
light: "glacier",
|
||||
};
|
||||
// New users default to the product's dark graphite look. Existing users, and any
|
||||
// user who manually changes the theme, keep their stored desktop config.
|
||||
const DEFAULT_THEME: Theme = "dark";
|
||||
|
||||
const THEME_KEY = "reasonix-theme";
|
||||
const STYLE_KEY = "reasonix-theme-style";
|
||||
let currentTheme: Theme = DEFAULT_THEME;
|
||||
let currentThemeStyle: ThemeStyle = DEFAULT_THEME_STYLE.dark;
|
||||
|
||||
function normalizeTheme(value: unknown): Theme | null {
|
||||
export function normalizeThemePreference(value: unknown): Theme {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return normalizeTheme((value as { mode?: unknown }).mode);
|
||||
return normalizeThemePreference((value as { mode?: unknown }).mode);
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
if (typeof value !== "string") return DEFAULT_THEME;
|
||||
switch (value) {
|
||||
case "auto":
|
||||
return "auto";
|
||||
@@ -54,7 +69,7 @@ function normalizeTheme(value: unknown): Theme | null {
|
||||
case "contrast":
|
||||
return "dark";
|
||||
default:
|
||||
return null;
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,14 +78,7 @@ export function isThemeStyle(value: unknown): value is ThemeStyle {
|
||||
}
|
||||
|
||||
export function getTheme(): Theme {
|
||||
const v = typeof localStorage !== "undefined" ? localStorage.getItem(THEME_KEY) : null;
|
||||
if (!v) return "auto";
|
||||
try {
|
||||
const parsed = JSON.parse(v) as unknown;
|
||||
return normalizeTheme(parsed) ?? normalizeTheme(v) ?? "auto";
|
||||
} catch {
|
||||
return normalizeTheme(v) ?? "auto";
|
||||
}
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
export function getResolvedTheme(theme: Theme = getTheme()): ResolvedTheme {
|
||||
@@ -88,12 +96,15 @@ export function themeForStyle(style: ThemeStyle): ResolvedTheme {
|
||||
}
|
||||
|
||||
export function getThemeStyle(theme: Theme = getTheme()): ThemeStyle {
|
||||
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(STYLE_KEY) : null;
|
||||
if (isThemeStyle(stored) && themeForStyle(stored) === getResolvedTheme(theme)) return stored;
|
||||
if (theme === currentTheme && themeForStyle(currentThemeStyle) === getResolvedTheme(theme)) return currentThemeStyle;
|
||||
return defaultStyleForTheme(theme);
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme)): void {
|
||||
export function normalizeThemeStyleForTheme(style: string | undefined, theme: Theme): ThemeStyle {
|
||||
return isThemeStyle(style) && themeForStyle(style) === getResolvedTheme(theme) ? style : defaultStyleForTheme(theme);
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme), options: { persist?: boolean } = {}): void {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.removeAttribute("data-theme-mode");
|
||||
@@ -103,16 +114,71 @@ export function applyTheme(theme: Theme, style: ThemeStyle = getThemeStyle(theme
|
||||
|
||||
const resolved = getResolvedTheme(theme);
|
||||
const nextStyle = themeForStyle(style) === resolved ? style : DEFAULT_THEME_STYLE[resolved];
|
||||
currentTheme = theme;
|
||||
currentThemeStyle = nextStyle;
|
||||
root.setAttribute("data-theme-style", nextStyle);
|
||||
|
||||
// Sync the native window theme (title bar, traffic lights) to match.
|
||||
if (typeof window !== "undefined" && window.runtime) {
|
||||
if (theme === "auto") {
|
||||
WindowSetSystemDefaultTheme();
|
||||
} else if (theme === "light") {
|
||||
WindowSetLightTheme();
|
||||
} else if (theme === "dark") {
|
||||
WindowSetDarkTheme();
|
||||
}
|
||||
}
|
||||
|
||||
void options;
|
||||
}
|
||||
|
||||
export function readLegacyThemePreference(): { theme: Theme; style: ThemeStyle; hasValue: boolean } {
|
||||
if (typeof localStorage === "undefined") return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
|
||||
let rawTheme: string | null = null;
|
||||
let rawStyle: string | null = null;
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
localStorage.setItem(STYLE_KEY, nextStyle);
|
||||
rawTheme = localStorage.getItem(THEME_KEY);
|
||||
rawStyle = localStorage.getItem(STYLE_KEY);
|
||||
} catch {
|
||||
/* private mode / no storage — the in-DOM attributes still apply */
|
||||
return { theme: DEFAULT_THEME, style: DEFAULT_THEME_STYLE.dark, hasValue: false };
|
||||
}
|
||||
const hasValue = rawTheme !== null || rawStyle !== null;
|
||||
let theme = DEFAULT_THEME;
|
||||
if (rawTheme) {
|
||||
try {
|
||||
theme = normalizeThemePreference(JSON.parse(rawTheme) as unknown);
|
||||
} catch {
|
||||
theme = normalizeThemePreference(rawTheme);
|
||||
}
|
||||
}
|
||||
const style = normalizeThemeStyleForTheme(rawStyle ?? undefined, theme);
|
||||
return { theme, style, hasValue };
|
||||
}
|
||||
|
||||
export function clearLegacyThemePreference(): void {
|
||||
try {
|
||||
localStorage.removeItem(THEME_KEY);
|
||||
localStorage.removeItem(STYLE_KEY);
|
||||
} catch {
|
||||
/* ignore storage failures */
|
||||
}
|
||||
}
|
||||
|
||||
// initTheme runs before React mounts. It applies the saved theme to the DOM and
|
||||
// sets the native window background colour to match the resolved theme, avoiding
|
||||
// a white (or wrong-colour) flash while the webview paints its first frame.
|
||||
export function initTheme(): void {
|
||||
const theme = getTheme();
|
||||
applyTheme(theme, getThemeStyle(theme));
|
||||
applyTheme(theme, getThemeStyle(theme), { persist: false });
|
||||
|
||||
if (typeof window !== "undefined" && window.runtime) {
|
||||
const resolved = getResolvedTheme(theme);
|
||||
if (resolved === "light") {
|
||||
// Light shell: matches :root[data-theme="light"] --bg (#f7f8fb).
|
||||
WindowSetBackgroundColour(247, 248, 251, 255);
|
||||
} else {
|
||||
// Dark shell: matches :root --bg (#090a0c).
|
||||
WindowSetBackgroundColour(9, 10, 12, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Todo } from "./tools";
|
||||
|
||||
export function shouldShowTodoPanel(
|
||||
todoId: string | null | undefined,
|
||||
dismissedTodoId: string | null,
|
||||
todos: Todo[],
|
||||
): boolean {
|
||||
return !!todoId && todoId !== dismissedTodoId && todos.length > 0;
|
||||
}
|
||||
@@ -49,6 +49,9 @@ export interface WireUsage {
|
||||
// hit-rate (Σhit/Σ(hit+miss)), steadier than the single-turn cacheHitTokens.
|
||||
sessionCacheHitTokens: number;
|
||||
sessionCacheMissTokens: number;
|
||||
cost?: number;
|
||||
currency?: string;
|
||||
// Deprecated compatibility alias. Prefer cost + currency.
|
||||
costUsd?: number;
|
||||
}
|
||||
|
||||
@@ -95,6 +98,90 @@ export interface WireEvent {
|
||||
err?: string;
|
||||
retryAttempt?: number;
|
||||
retryMax?: number;
|
||||
// Tab routing: set by the Go-side tabEventSink so multi-tab frontends
|
||||
// route each event to the correct per-tab reducer.
|
||||
tabId?: string;
|
||||
sessionHitTokens?: number;
|
||||
sessionMissTokens?: number;
|
||||
sessionCost?: number;
|
||||
sessionCurrency?: string;
|
||||
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
|
||||
sessionCostUsd?: number;
|
||||
}
|
||||
|
||||
// Tab management types (desktop/tabs.go).
|
||||
export interface TabMeta {
|
||||
id: string;
|
||||
tabType?: "session" | "file";
|
||||
scope: string;
|
||||
workspaceRoot: string;
|
||||
workspaceName: string;
|
||||
topicId: string;
|
||||
topicTitle: string;
|
||||
filePath?: string;
|
||||
projectColor?: string;
|
||||
label: string;
|
||||
ready: boolean;
|
||||
running: boolean;
|
||||
mode: Mode;
|
||||
startupErr?: string;
|
||||
active: boolean;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export interface ProjectNode {
|
||||
key: string;
|
||||
kind: "project" | "topic" | "global_folder" | "global_topic";
|
||||
label: string;
|
||||
root?: string;
|
||||
topicId?: string;
|
||||
projectColor?: string;
|
||||
turns?: number;
|
||||
lastActivityAt?: number;
|
||||
open?: boolean;
|
||||
running?: boolean;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
export interface TopicMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ContextPanelInfo {
|
||||
usedTokens: number;
|
||||
windowTokens: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
reasoningTokens: number;
|
||||
cacheHitTokens: number;
|
||||
cacheMissTokens: number;
|
||||
sessionCost?: number;
|
||||
sessionCurrency?: string;
|
||||
// Deprecated compatibility alias. Prefer sessionCost + sessionCurrency.
|
||||
sessionCostUsd?: number;
|
||||
readFiles: ReadFileRecord[];
|
||||
changedFiles: ChangedFileInfo[];
|
||||
}
|
||||
|
||||
export interface ReadFileRecord {
|
||||
path: string;
|
||||
turn: number;
|
||||
time: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
export interface ChangedFileInfo {
|
||||
path: string;
|
||||
oldPath?: string;
|
||||
sources: string[];
|
||||
gitStatus?: string;
|
||||
turns: number[];
|
||||
latestPrompt?: string;
|
||||
latestTime?: number;
|
||||
}
|
||||
|
||||
// Bound-method payloads (desktop/app.go).
|
||||
@@ -110,6 +197,8 @@ export interface CheckpointMeta {
|
||||
prompt: string;
|
||||
files: string[];
|
||||
time: number; // unix ms
|
||||
canCode?: boolean;
|
||||
canConversation?: boolean;
|
||||
}
|
||||
|
||||
// SessionMeta is one saved session for the history panel.
|
||||
@@ -118,10 +207,16 @@ export interface SessionMeta {
|
||||
preview: string;
|
||||
title?: string; // user-chosen name; falls back to preview when empty
|
||||
turns: number;
|
||||
createdAt?: number; // unix milliseconds
|
||||
lastActivityAt?: number; // unix milliseconds
|
||||
createdAt: number; // unix milliseconds
|
||||
lastActivityAt: number; // unix milliseconds
|
||||
modTime: number; // compatibility alias for lastActivityAt
|
||||
deletedAt?: number; // unix milliseconds, present for trashed sessions
|
||||
current: boolean;
|
||||
open: boolean;
|
||||
scope?: string; // "project" | "global"; empty for legacy → treated as "global"
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
topicTitle?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceView {
|
||||
@@ -133,6 +228,7 @@ export interface WorkspaceView {
|
||||
export interface ContextInfo {
|
||||
used: number;
|
||||
window: number;
|
||||
compactRatio?: number;
|
||||
}
|
||||
|
||||
export interface Meta {
|
||||
@@ -144,8 +240,8 @@ export interface Meta {
|
||||
bypass?: boolean; // YOLO mode on (auto-approve every tool call)
|
||||
}
|
||||
|
||||
// Mode is the input mode cycled by Shift+Tab: normal → plan (read-only) → yolo
|
||||
// (auto-approve every tool call; deny rules still apply).
|
||||
// Mode is the input mode cycled by Shift+Tab: normal (shown as auto) → plan
|
||||
// (read-only) → yolo (auto-approve every tool call; deny rules still apply).
|
||||
export type Mode = "normal" | "plan" | "yolo";
|
||||
|
||||
export interface CommandInfo {
|
||||
@@ -211,14 +307,14 @@ export interface ServerView {
|
||||
args?: string[];
|
||||
url?: string;
|
||||
envKeys?: string[];
|
||||
authStatus?: string;
|
||||
authUrl?: string;
|
||||
authConfigured?: boolean;
|
||||
tools: number;
|
||||
prompts: number;
|
||||
resources: number;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
authStatus?: "none" | "possible" | "required" | string;
|
||||
authUrl?: string;
|
||||
authConfigured?: boolean;
|
||||
}
|
||||
export interface MCPToolView {
|
||||
name: string;
|
||||
@@ -229,6 +325,7 @@ export interface SkillView {
|
||||
description: string;
|
||||
scope: string;
|
||||
runAs: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface SkillRootSkillView {
|
||||
name: string;
|
||||
@@ -327,6 +424,8 @@ export interface ProviderView {
|
||||
keySet: boolean; // the env var currently resolves to a value
|
||||
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
|
||||
contextWindow: number;
|
||||
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
|
||||
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
|
||||
}
|
||||
|
||||
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
|
||||
@@ -385,11 +484,16 @@ export interface AgentView {
|
||||
export interface SettingsView {
|
||||
defaultModel: string;
|
||||
plannerModel: string;
|
||||
autoPlan: string;
|
||||
providers: ProviderView[];
|
||||
permissions: PermissionsView;
|
||||
sandbox: SandboxView;
|
||||
network: NetworkView;
|
||||
agent: AgentView;
|
||||
desktopLanguage: string; // "" | "en" | "zh"; empty = auto
|
||||
desktopTheme: string; // "auto" | "dark" | "light"
|
||||
desktopThemeStyle: string;
|
||||
closeBehavior: string; // "background" | "quit"
|
||||
configPath: string;
|
||||
providerKinds: string[]; // provider implementations the kernel registered (for the kind picker)
|
||||
bypass: boolean; // live YOLO state (runtime-only) — whether approvals are skipped this session
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
// useWindowStatePersistence polls the Wails runtime for window geometry and
|
||||
// persists it via SaveWindowState so the next launch restores the same size and
|
||||
// position. No-op in browser dev (no window.runtime).
|
||||
//
|
||||
// The Go shutdown hook (app.saveWindowStateSync) provides the authoritative
|
||||
// final save; this hook covers moves/resizes during the session.
|
||||
//
|
||||
// NOTE: navigator.sendBeacon or a sync XHR would let us block beforeunload,
|
||||
// but Wails bindings are async (Go IPC). We accept that the very last resize
|
||||
// event may not land; the 5s poll and the Go shutdown hook make this unlikely
|
||||
// to matter.
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
WindowGetPosition,
|
||||
WindowGetSize,
|
||||
WindowIsMaximised,
|
||||
} from "../../wailsjs/runtime/runtime";
|
||||
import { app } from "./bridge";
|
||||
|
||||
export function useWindowStatePersistence() {
|
||||
const lastState = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.runtime) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval>;
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const size = await WindowGetSize();
|
||||
const pos = await WindowGetPosition();
|
||||
const maximised = await WindowIsMaximised();
|
||||
const json = JSON.stringify({
|
||||
width: size.w,
|
||||
height: size.h,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
maximised,
|
||||
});
|
||||
if (json !== lastState.current) {
|
||||
await app.SaveWindowState({ width: size.w, height: size.h, x: pos.x, y: pos.y, maximised });
|
||||
lastState.current = json;
|
||||
}
|
||||
} catch {
|
||||
/* runtime not ready yet — poll will retry */
|
||||
}
|
||||
};
|
||||
|
||||
// Debounced save on resize (500ms after the last resize event).
|
||||
let debounce: ReturnType<typeof setTimeout>;
|
||||
const onResize = () => {
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(save, 500);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
|
||||
// Periodic poll every 5s for moves/maximise that don't trigger resize.
|
||||
timer = setInterval(save, 5000);
|
||||
|
||||
// Best-effort save before the page unloads. The Go shutdown hook
|
||||
// (saveWindowStateSync) is the authoritative final persist.
|
||||
window.addEventListener("beforeunload", save);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
clearTimeout(debounce);
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.removeEventListener("beforeunload", save);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -27,11 +27,17 @@ export const en = {
|
||||
"topbar.newSession": "New session",
|
||||
"topbar.startupError": "startup error: {msg}",
|
||||
|
||||
// tab bar
|
||||
"tabBar.closeTab": "Close tab",
|
||||
"tabBar.closeOtherTabs": "Close other tabs",
|
||||
"tabBar.closeTabsToRight": "Close tabs to right",
|
||||
"tabBar.newSession": "New session",
|
||||
"tabBar.tabActions": "Tab actions",
|
||||
|
||||
// sidebar
|
||||
"sidebar.recent": "Recent",
|
||||
"sidebar.conversations": "Chats",
|
||||
"sidebar.viewAll": "All",
|
||||
"sidebar.noRecent": "No recent chats",
|
||||
"sidebar.allHistory": "All history",
|
||||
"sidebar.trash": "Trash",
|
||||
"sidebar.workspace": "Workspace",
|
||||
"sidebar.changeWorkspace": "Change",
|
||||
"sidebar.navigation": "Reasonix navigation",
|
||||
@@ -41,20 +47,37 @@ export const en = {
|
||||
"sidebar.resize": "Resize sidebar",
|
||||
"drawer.resize": "Resize drawer",
|
||||
|
||||
// right dock
|
||||
"rightDock.resize": "Resize right panel",
|
||||
"rightDock.expand": "Expand right workspace",
|
||||
"rightDock.collapse": "Collapse right workspace",
|
||||
"rightDock.workbench": "Right workbench",
|
||||
"rightDock.views": "Right workbench views",
|
||||
"rightDock.overview": "Overview",
|
||||
|
||||
// topic bar
|
||||
"topicBar.renameSession": "Rename session",
|
||||
"topicBar.more": "More",
|
||||
|
||||
// scope labels
|
||||
"scope.global": "Scope: Global",
|
||||
"scope.project": "Project · {name}",
|
||||
|
||||
// workspace panel
|
||||
"workspace.title": "Workspace",
|
||||
"workspace.open": "Open workspace panel",
|
||||
"workspace.close": "Close workspace panel",
|
||||
"workspace.minimize": "Minimize workspace panel",
|
||||
"workspace.maximize": "Maximize workspace panel",
|
||||
"workspace.restore": "Restore workspace panel",
|
||||
"workspace.resizePanel": "Resize workspace panel",
|
||||
"workspace.hideTree": "Hide file tree",
|
||||
"workspace.showTree": "Show file tree",
|
||||
"workspace.resizeTree": "Resize file tree",
|
||||
"workspace.newTab": "Open another file",
|
||||
"workspace.closeTab": "Close tab",
|
||||
"workspace.currentFile": "Current file",
|
||||
"workspace.recentFiles": "Recent files",
|
||||
"workspace.closePreview": "Close preview",
|
||||
"workspace.filter": "Filter files…",
|
||||
"workspace.filterChanges": "Filter changes…",
|
||||
"workspace.noFile": "No file selected",
|
||||
"workspace.pickFile": "Choose a file from the tree.",
|
||||
"workspace.loading": "Loading file…",
|
||||
@@ -66,7 +89,9 @@ export const en = {
|
||||
"workspace.addFileContentToChat": "Add file contents",
|
||||
"workspace.viewMode": "Workspace view",
|
||||
"workspace.filesTab": "Files",
|
||||
"workspace.changedTab": "Changed",
|
||||
"workspace.changedTab": "Changes",
|
||||
"workspace.treeMenu": "File tree actions",
|
||||
"workspace.refreshTree": "Refresh file tree",
|
||||
"workspace.refreshChanges": "Refresh changes",
|
||||
"workspace.loadingChanges": "Loading changes…",
|
||||
"workspace.noChanges": "No changed files",
|
||||
@@ -106,6 +131,9 @@ export const en = {
|
||||
"caps.skillScopeProject": "Project",
|
||||
"caps.skillScopeCustom": "Custom",
|
||||
"caps.skillScopeGlobal": "Global",
|
||||
"caps.skillDisabled": "Disabled",
|
||||
"caps.disableSkill": "Disable skill",
|
||||
"caps.enableSkill": "Enable skill",
|
||||
"caps.subagent": "Subagent",
|
||||
"caps.addServer": "+ Add server",
|
||||
"caps.add": "Add",
|
||||
@@ -140,6 +168,13 @@ export const en = {
|
||||
"caps.reauthorize": "Reauthorize",
|
||||
"caps.checkCommand": "Check command",
|
||||
"caps.builtIn": "Built-in",
|
||||
"caps.auth": "Auth",
|
||||
"caps.authRequired": "authorization required",
|
||||
"caps.authRequiredSummary": "This MCP server requires authorization before it can connect.",
|
||||
"caps.authPossible": "authorization may be required",
|
||||
"caps.authPossibleShort": "auth may be required",
|
||||
"caps.clearAuth": "Clear auth",
|
||||
"caps.confirmClearAuth": "Confirm clear",
|
||||
"caps.codegraphWarming": "codegraph: built-in code intelligence is still warming up or preparing the project index; retry shortly",
|
||||
"caps.deferred": "enabled; connects when this MCP is used",
|
||||
"caps.initializing": "connecting to MCP; tools will appear when ready",
|
||||
@@ -194,18 +229,21 @@ export const en = {
|
||||
"composer.planModeOn": "plan mode on",
|
||||
"composer.planHint": "shift+tab",
|
||||
"composer.planHintExit": "shift+tab to exit",
|
||||
"composer.modeNormal": "normal",
|
||||
"composer.modePlan": "plan mode",
|
||||
"composer.modeNormal": "auto",
|
||||
"composer.modePlan": "plan",
|
||||
"composer.modeYolo": "YOLO",
|
||||
"composer.modeHint": "shift+tab",
|
||||
"composer.modeTitle": "Cycle mode (shift+tab): normal → plan → YOLO (auto-approve everything)",
|
||||
"composer.modeTitle": "Cycle run mode (shift+tab): auto → plan → yolo",
|
||||
"composer.enterPlanTitle": "Enter plan mode (shift+tab) — read-only; propose a plan before writing",
|
||||
"composer.exitPlanTitle": "Exit plan mode (shift+tab)",
|
||||
"composer.searchProjects": "Search projects",
|
||||
"composer.noProjectMatches": "No matching projects",
|
||||
"composer.addProject": "Add new project",
|
||||
"composer.removeProject": "Remove project",
|
||||
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
|
||||
"composer.send": "Send (Enter)",
|
||||
"composer.stop": "Stop (Esc)",
|
||||
"composer.stopShort": "Stop",
|
||||
"composer.pastedLabel": "[Pasted text #{id} · {lines} lines]",
|
||||
"composer.pastedShowPreview": "Preview pasted text",
|
||||
"composer.pastedHidePreview": "Hide pasted text preview",
|
||||
@@ -218,7 +256,8 @@ export const en = {
|
||||
|
||||
// status bar
|
||||
"status.connecting": "connecting…",
|
||||
"status.ctx": "{pct}% ctx",
|
||||
"status.ctx": "ctx {pct}%",
|
||||
"status.compact": "compact {pct}%",
|
||||
"status.plan": "PLAN",
|
||||
"status.switchModel": "Switch model",
|
||||
"status.noModels": "no models configured",
|
||||
@@ -228,12 +267,18 @@ export const en = {
|
||||
"status.switchFolder": "{cwd}\nClick to switch project folder",
|
||||
"status.tokens": "tokens",
|
||||
"status.retrying": "retrying ({attempt}/{max})…",
|
||||
"status.cache": "cache {pct}%",
|
||||
"status.cache": "turn hit {pct}%",
|
||||
"status.cacheAvg": "avg {pct}%",
|
||||
"status.ctxUnknown": "ctx -",
|
||||
"status.compactUnknown": "compact -",
|
||||
"status.cost": "run {amount}",
|
||||
"status.balanceTitle": "Wallet balance",
|
||||
"status.balance": "balance {amount}",
|
||||
"status.spendTitle": "Spent this session",
|
||||
"status.jobs": "{n} running",
|
||||
"status.jobsCount": "jobs {n}",
|
||||
"status.jobsTitle": "Background jobs",
|
||||
"status.workspaceTitle": "Current workspace",
|
||||
"status.yolo": "YOLO",
|
||||
"status.yoloTitle": "YOLO — approvals are being skipped this session",
|
||||
|
||||
@@ -265,13 +310,28 @@ export const en = {
|
||||
|
||||
// history drawer
|
||||
"history.title": "History",
|
||||
"history.trashTitle": "Trash",
|
||||
"history.empty": "No saved sessions yet.",
|
||||
"history.trashEmpty": "Trash is empty.",
|
||||
"history.trashHint": "Deleted sessions live here first. Restore them or delete them permanently.",
|
||||
"history.namePlaceholder": "Session name…",
|
||||
"history.emptySession": "(empty session)",
|
||||
"history.current": "current",
|
||||
"history.open": "open",
|
||||
"history.deleted": "deleted",
|
||||
"history.turnOne": "{n} turn",
|
||||
"history.turnOther": "{n} turns",
|
||||
"history.confirmDelete": "Confirm delete",
|
||||
"history.confirmPurge": "Confirm permanent delete",
|
||||
"history.clearTrash": "Empty trash",
|
||||
"history.confirmClearTrash": "Confirm empty trash",
|
||||
"history.clearTrashMenu": "Empty trash…",
|
||||
"history.moveToTrash": "Move to trash",
|
||||
"history.confirmMoveToTrash": "Confirm move to trash",
|
||||
"history.restore": "Restore",
|
||||
"history.restoreSession": "Restore session",
|
||||
"history.purge": "Delete permanently",
|
||||
"history.purgeSession": "Delete permanently…",
|
||||
"history.rename": "Rename",
|
||||
"history.preview": "preview",
|
||||
"history.previewEmpty": "No visible messages in this session.",
|
||||
@@ -280,6 +340,42 @@ export const en = {
|
||||
"history.noResults": "No matching sessions.",
|
||||
"history.today": "Today",
|
||||
"history.yesterday": "Yesterday",
|
||||
"history.trashSessionActions": "Trash session actions",
|
||||
"history.historySessionActions": "History session actions",
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Project workspace",
|
||||
"projectTree.searchPlaceholder": "Search projects or sessions",
|
||||
"projectTree.newTopic": "New session",
|
||||
"projectTree.renameTopic": "Rename session",
|
||||
"projectTree.renameProject": "Rename display name",
|
||||
"projectTree.projectHistory": "View project history",
|
||||
"projectTree.removeProject": "Remove from sidebar",
|
||||
"projectTree.confirmRemoveProject": "Confirm remove from sidebar",
|
||||
"projectTree.copyPath": "Copy path",
|
||||
"projectTree.revealInFinder": "Show in Finder",
|
||||
"projectTree.revealInExplorer": "Show in File Explorer",
|
||||
"projectTree.revealInFileManager": "Show in file manager",
|
||||
"projectTree.topicActions": "Session actions",
|
||||
"projectTree.projectActions": "Project actions",
|
||||
"projectTree.colorDefault": "Default",
|
||||
"projectTree.colorRed": "Red",
|
||||
"projectTree.colorOrange": "Orange",
|
||||
"projectTree.colorAmber": "Amber",
|
||||
"projectTree.colorGreen": "Green",
|
||||
"projectTree.colorTeal": "Teal",
|
||||
"projectTree.colorBlue": "Blue",
|
||||
"projectTree.colorPurple": "Purple",
|
||||
"projectTree.colorPink": "Pink",
|
||||
"projectTree.emptyNoMatch": "No matching projects or sessions",
|
||||
"projectTree.emptyNoProjects": "No projects yet",
|
||||
"projectTree.emptyNoProjectsDesc": "Add projects to organize sessions, files, and context.",
|
||||
"projectTree.emptyAddProject": "Add new project",
|
||||
"projectTree.emptyUseCurrentDir": "Use current directory",
|
||||
"projectTree.emptyCurrentDir": "Current directory: {dir}",
|
||||
"projectTree.newTopicTooltip": "New session",
|
||||
"projectTree.addProjectTooltip": "Add new project",
|
||||
|
||||
// memory drawer
|
||||
"memory.title": "Memory",
|
||||
@@ -312,32 +408,42 @@ export const en = {
|
||||
"settings.title": "Settings",
|
||||
"settings.loading": "Loading…",
|
||||
"settings.modelsProviders": "Models & providers",
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.models": "Models",
|
||||
"settings.tab.providers": "Providers",
|
||||
"settings.tab.network": "Network",
|
||||
"settings.tab.permissions": "Permissions",
|
||||
"settings.tab.sandbox": "Sandbox",
|
||||
"settings.tab.agent": "Agent",
|
||||
"settings.tab.appearance": "Appearance",
|
||||
"settings.tab.updates": "Updates",
|
||||
"settings.providerCount": "{n} providers",
|
||||
"settings.agentMeta": "temp {temp} · steps {steps}",
|
||||
"settings.appearanceMeta": "theme · accent · language",
|
||||
"settings.appearanceMeta": "theme · accent · text",
|
||||
"settings.updatesMeta": "version · config",
|
||||
"settings.closeBehavior": "When closing window",
|
||||
"settings.closeBehavior.background": "Keep running",
|
||||
"settings.closeBehavior.quit": "Quit Reasonix",
|
||||
"settings.manageProviders": "Manage providers",
|
||||
"settings.activeProvider": "Active provider",
|
||||
"settings.plannerStatus": "Planner",
|
||||
"settings.plannerEnabled": "enabled",
|
||||
"settings.plannerDisabled": "disabled",
|
||||
"settings.plannerStatus": "Planning mode",
|
||||
"settings.plannerEnabled": "two-model",
|
||||
"settings.plannerDisabled": "single-model",
|
||||
"settings.plannerSingle": "single model",
|
||||
"settings.plannerDual": "two models",
|
||||
"settings.plannerSingleDetail": "{model} plans and executes",
|
||||
"settings.plannerDualDetail": "{planner} plans · {executor} executes",
|
||||
"settings.providers": "Providers",
|
||||
"settings.availableModels": "Available models",
|
||||
"settings.defaultModel": "Default model",
|
||||
"settings.plannerModel": "Planner model",
|
||||
"settings.plannerNone": "(none — single model)",
|
||||
"settings.plannerModel": "Dedicated planner model",
|
||||
"settings.plannerNone": "Use current model (single model)",
|
||||
"settings.autoPlan": "Automatic plan mode",
|
||||
"settings.autoPlan.off": "Off",
|
||||
"settings.autoPlan.on": "On",
|
||||
"settings.keySet": "key set",
|
||||
"settings.noKey": "no key",
|
||||
"settings.cantDeleteDefault": "Can't delete the default provider",
|
||||
"settings.deleteProvider": "Delete provider",
|
||||
"settings.confirmDeleteProvider": "Confirm delete provider",
|
||||
"settings.addProvider": "+ Add provider",
|
||||
"settings.providerName": "name (e.g. deepseek-flash)",
|
||||
"settings.providerKind": "Kind",
|
||||
@@ -350,6 +456,12 @@ export const en = {
|
||||
"settings.providerContextWindow": "Context window",
|
||||
"settings.contextWindowPlaceholder": "tokens (0 = provider default)",
|
||||
"settings.contextWindowHint": "Max tokens to keep in context for this provider. Leave 0 to use the provider's default.",
|
||||
"settings.supportedEfforts": "Supported efforts",
|
||||
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
|
||||
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
|
||||
"settings.defaultEffort": "Default effort",
|
||||
"settings.defaultEffortAuto": "auto (use built-in default)",
|
||||
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
|
||||
"settings.proxyMode": "Proxy mode",
|
||||
"settings.proxyMode.auto": "auto",
|
||||
"settings.proxyMode.env": "env",
|
||||
@@ -364,7 +476,7 @@ export const en = {
|
||||
"settings.proxyUrlHint": "Optional. When set, proxy_url overrides the structured server and port fields.",
|
||||
"settings.noProxy": "No proxy",
|
||||
"settings.saveNetwork": "Save network settings",
|
||||
"settings.setKey": "set {env} (→ .env)",
|
||||
"settings.setKey": "set {env} (saved globally)",
|
||||
"settings.saveKey": "Save key",
|
||||
"settings.permissions": "Permissions",
|
||||
"settings.writerMode": "Writer mode",
|
||||
@@ -382,13 +494,7 @@ export const en = {
|
||||
"settings.allowNetwork": "Allow network egress from sandboxed bash",
|
||||
"settings.workspaceRoot": "Workspace root",
|
||||
"settings.workspaceDefault": "(default: cwd)",
|
||||
"settings.agent": "Agent",
|
||||
"settings.temperature": "Temperature",
|
||||
"settings.maxSteps": "Max steps",
|
||||
"settings.unlimited": "0 = unlimited",
|
||||
"settings.systemPrompt": "System prompt",
|
||||
"settings.saveAgent": "Save agent settings",
|
||||
"settings.appearance": "Appearance & language",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeStyle": "Accent",
|
||||
"settings.themeAuto": "auto",
|
||||
@@ -397,6 +503,11 @@ export const en = {
|
||||
"settings.themeCurrent": "Theme: {theme} / {style}",
|
||||
"settings.themeChanged": "Theme changed to {theme} / {style}",
|
||||
"settings.themeUnknown": "Unknown theme: {name}",
|
||||
"settings.textSize": "Text size",
|
||||
"settings.textSizeSmall": "Small",
|
||||
"settings.textSizeDefault": "Default",
|
||||
"settings.textSizeLarge": "Large",
|
||||
"settings.textSizeXLarge": "Extra large",
|
||||
"settings.language": "Language",
|
||||
"settings.langAuto": "Auto (system)",
|
||||
"settings.config": "config: {path}",
|
||||
@@ -404,6 +515,7 @@ export const en = {
|
||||
// todo bar
|
||||
"todo.title": "To-dos",
|
||||
"todo.dismiss": "Dismiss the task list",
|
||||
"todo.stale": "Progress may be stale",
|
||||
|
||||
// slash menu tags
|
||||
"slash.project": "project",
|
||||
@@ -413,24 +525,47 @@ export const en = {
|
||||
// assistant message
|
||||
"msg.thinking": "thinking",
|
||||
"msg.copy": "Copy",
|
||||
"questionNav.label": "Question navigation",
|
||||
"questionNav.progress": "Question {current} / {total}",
|
||||
"questionNav.jump": "Jump to question {n}",
|
||||
"questionNav.hint": "Click to jump to this question",
|
||||
"compaction.working": "Compacting conversation…",
|
||||
"compaction.title": "Context compacted",
|
||||
"compaction.messages": "{n} messages",
|
||||
"compaction.showSummary": "show summary",
|
||||
"compaction.hideSummary": "hide summary",
|
||||
"rewind.label": "Rewind / fork / summarize…",
|
||||
"rewind.both": "Code + conversation",
|
||||
"rewind.conversation": "Conversation only",
|
||||
"rewind.code": "Code only",
|
||||
"rewind.fork": "Fork (new branch)",
|
||||
"rewind.summFrom": "Summarize from here",
|
||||
"rewind.summUpto": "Summarize up to here",
|
||||
"rewind.label": "Message actions",
|
||||
"rewind.disabledRunning": "Wait for the current action to finish",
|
||||
"rewind.disabledNoCheckpoint": "This message has no restore point",
|
||||
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
|
||||
"rewind.disabledNoCode": "No code changes were captured after this message",
|
||||
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
|
||||
"rewind.filesChanged": "{count} files",
|
||||
"rewind.both": "Rewind code and conversation",
|
||||
"rewind.conversation": "Rewind conversation only",
|
||||
"rewind.code": "Rewind code only",
|
||||
"rewind.fork": "Fork conversation",
|
||||
"rewind.summFrom": "Summarize after this",
|
||||
"rewind.summUpto": "Summarize before this",
|
||||
"rewind.confirmFork": "Confirm fork conversation",
|
||||
"rewind.confirmSummFrom": "Confirm summarize after this",
|
||||
"rewind.confirmSummUpto": "Confirm summarize before this",
|
||||
"rewind.confirmBoth": "Confirm rewind code and conversation",
|
||||
"rewind.confirmConversation": "Confirm rewind conversation",
|
||||
"rewind.confirmCode": "Confirm rewind code",
|
||||
"rewind.busyFork": "Forking from this message…",
|
||||
"rewind.busySummFrom": "Summarizing after this message…",
|
||||
"rewind.busySummUpto": "Summarizing up to this message…",
|
||||
"rewind.busyConversation": "Rewinding conversation…",
|
||||
"rewind.busyCode": "Rewinding code…",
|
||||
"rewind.busyBoth": "Rewinding code and conversation…",
|
||||
"msg.copied": "Copied",
|
||||
|
||||
// tool card summaries
|
||||
"tool.stepOne": "{n} step",
|
||||
"tool.stepOther": "{n} steps",
|
||||
"tool.truncated": "output truncated",
|
||||
"tool.showAllLines": "show all {n} lines",
|
||||
"tool.lineOne": "{n} line",
|
||||
"tool.lineOther": "{n} lines",
|
||||
"tool.matchOne": "{n} match",
|
||||
@@ -465,18 +600,110 @@ export const en = {
|
||||
|
||||
// onboarding — first-run API-key overlay
|
||||
"onboarding.title": "Connect Reasonix",
|
||||
"onboarding.tagline": "Paste a DeepSeek API key to start. Stored locally in .env, never sent anywhere else.",
|
||||
"onboarding.tagline": "Paste a DeepSeek API key to start. Stored locally in this app's credentials, never sent anywhere else.",
|
||||
"onboarding.inputLabel": "API key",
|
||||
"onboarding.inputPlaceholder": "sk-…",
|
||||
"onboarding.submit": "Connect & start",
|
||||
"onboarding.validating": "Validating…",
|
||||
"onboarding.getKey": "How do I get a key?",
|
||||
"onboarding.privacy": "Stored only in this app's .env",
|
||||
"onboarding.privacy": "Stored only in this app's local credentials",
|
||||
"onboarding.error.empty": "Please paste a key first.",
|
||||
"onboarding.error.invalid": "That key didn't work — double-check it's active and has billing set up.",
|
||||
"onboarding.error.network": "Couldn't reach DeepSeek — check your network and try again.",
|
||||
"onboarding.error.unknown": "Something went wrong: {msg}",
|
||||
"onboarding.skip": "Skip for now",
|
||||
|
||||
// context panel
|
||||
"context.overview": "Session overview",
|
||||
"context.scopeGlobal": "Scope: Global",
|
||||
"context.scopeProject": "Project · {name}",
|
||||
"context.sessionChanges": "Session changes",
|
||||
"context.referencedFiles": "Referenced files",
|
||||
"context.noChanges": "No changed files in this session yet",
|
||||
"context.noReads": "No files read in this session yet",
|
||||
"context.filterChanges": "Filter session changes...",
|
||||
"context.filterReads": "Filter referenced files...",
|
||||
"context.changedNote": "This session is linked to {count} change records",
|
||||
"context.readNote": "This session has read {count} file records",
|
||||
"context.changedMeta": "{count} change records",
|
||||
"context.readMeta": "{count} read records",
|
||||
"context.viewAll": "View all",
|
||||
"context.health": "Context health",
|
||||
"context.healthGood": "Context healthy",
|
||||
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
|
||||
"context.healthNearLimit": "Context near limit",
|
||||
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
|
||||
"context.healthManyFiles": "Many source files",
|
||||
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
|
||||
"context.healthLowCache": "Low cache hit",
|
||||
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
|
||||
"context.prompt": "Prompt",
|
||||
"context.completion": "Completion",
|
||||
"context.reasoning": "Reasoning",
|
||||
"context.other": "Other",
|
||||
"context.total": "Total",
|
||||
"context.cacheHit": "Cache hit",
|
||||
"context.sessionCost": "Session cost",
|
||||
"context.requests": "Requests",
|
||||
"context.time": "Time",
|
||||
"context.durationSeconds": "{seconds}s",
|
||||
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
|
||||
|
||||
// crash page
|
||||
"crash.title": "Reasonix hit an error — screenshot this and send it over",
|
||||
"crash.copy": "Copy",
|
||||
|
||||
// mock / demo seed data (browser dev only)
|
||||
"mock.sessionFixLogin": "fix the login bug in auth.go",
|
||||
"mock.sessionRefactor": "refactor the payment module",
|
||||
"mock.sessionReadme": "write the README and badges",
|
||||
"mock.sessionPlugin": "explain the plugin host design",
|
||||
"mock.trashDevStandardTitle": "00 Establish project development standards",
|
||||
"mock.trashDevStandardPreview": "Organize project development standards including directory structure, branch conventions, and commit requirements.",
|
||||
"mock.trashP3aTitle": "20260521 p3a P&D",
|
||||
"mock.trashP3aPreview": "Review p3a P&D scope, document remaining risks and next-round suggestions.",
|
||||
"mock.trashGlobalProductTitle": "Common product specs",
|
||||
"mock.trashGlobalProductPreview": "Document cross-project common product rules including acceptance, canary, and error handling.",
|
||||
"mock.projectJoyquantDb": "joyquant-db",
|
||||
"mock.projectJoyquantSys": "joyquant-sys",
|
||||
"mock.topicDevStandard": "00 Establish project dev standards",
|
||||
"mock.topicDbMaint": "01 JoyQuant DB project maintenance",
|
||||
"mock.topicEnv": "Project environment uv and Python runtime issues",
|
||||
"mock.topicP3b": "20260523 p3b P&D",
|
||||
"mock.topicP3a": "20260521 p3a P&D",
|
||||
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
|
||||
"mock.topicSysCoord": "01 JoyQuant-SYS project coordination",
|
||||
"mock.topicSysStandard": "00 Establish project dev standards",
|
||||
"mock.topicProduct": "Common product specs",
|
||||
"mock.topicAi": "AI engineering best practices",
|
||||
"mock.topicLab": "Exploratory experiments",
|
||||
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
|
||||
"mock.askQ1Header": "Approach",
|
||||
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
|
||||
"mock.askQ1Opt1Label": "git stash then pull",
|
||||
"mock.askQ1Opt1Desc": "Use git stash to save local changes, pull latest code, then restore",
|
||||
"mock.askQ1Opt2Label": "Discard local changes then pull",
|
||||
"mock.askQ1Opt2Desc": "Abandon all local changes, force sync with remote",
|
||||
"mock.askQ1Opt3Label": "Create branch to save changes",
|
||||
"mock.askQ1Opt3Desc": "Create a branch to save local changes first, then pull main branch",
|
||||
"mock.askQ2Header": "Reasonix build",
|
||||
"mock.askQ2Prompt": "What do you want to do about the missing reasonix binary?",
|
||||
"mock.askQ2Opt1Label": "Check docs first",
|
||||
"mock.askQ2Opt1Desc": "Read README / build docs to determine the correct build command",
|
||||
"mock.askQ2Opt2Label": "Check build config",
|
||||
"mock.askQ2Opt2Desc": "Check desktop/wails.json and main.go to infer the entry point",
|
||||
"mock.askQ2Opt3Label": "Let me try building",
|
||||
"mock.askQ2Opt3Desc": "Handle git conflicts first, then attempt local build and report results",
|
||||
"mock.todo1": "Review current frontend structure",
|
||||
"mock.todo2": "Optimize approval and Ask decision bar layout",
|
||||
"mock.todo2ActiveForm": "Adjusting decision bar layout",
|
||||
"mock.todo3": "Verify three preview states",
|
||||
"mock.newSession": "New session",
|
||||
"mock.memoryBody": "# User memory\n\nAlways respond in English.",
|
||||
"mock.changedFile1Prompt": "Update draft spec v0.3",
|
||||
"mock.changedFile2Prompt": "Record project context",
|
||||
"mock.changedFile1Path": "docs/dev-standard.md",
|
||||
"mock.changedFile2Path": ".reasonix/project.md",
|
||||
};
|
||||
|
||||
export type DictKey = keyof typeof en;
|
||||
@@ -28,11 +28,17 @@ export const zh: Record<DictKey, string> = {
|
||||
"topbar.newSession": "新建会话",
|
||||
"topbar.startupError": "启动错误:{msg}",
|
||||
|
||||
// 标签页栏
|
||||
"tabBar.closeTab": "关闭标签页",
|
||||
"tabBar.closeOtherTabs": "关闭其他标签页",
|
||||
"tabBar.closeTabsToRight": "关闭右侧标签页",
|
||||
"tabBar.newSession": "新建会话",
|
||||
"tabBar.tabActions": "标签页操作",
|
||||
|
||||
// 侧边栏
|
||||
"sidebar.recent": "最近",
|
||||
"sidebar.conversations": "会话",
|
||||
"sidebar.viewAll": "全部",
|
||||
"sidebar.noRecent": "还没有最近会话",
|
||||
"sidebar.allHistory": "全部历史",
|
||||
"sidebar.trash": "回收站",
|
||||
"sidebar.workspace": "工作区",
|
||||
"sidebar.changeWorkspace": "更改",
|
||||
"sidebar.navigation": "Reasonix 导航",
|
||||
@@ -42,20 +48,37 @@ export const zh: Record<DictKey, string> = {
|
||||
"sidebar.resize": "调整侧边栏宽度",
|
||||
"drawer.resize": "调整抽屉宽度",
|
||||
|
||||
// 右侧停靠区
|
||||
"rightDock.resize": "调整右侧面板宽度",
|
||||
"rightDock.expand": "展开右侧工作区",
|
||||
"rightDock.collapse": "收起右侧工作区",
|
||||
"rightDock.workbench": "右侧工作台",
|
||||
"rightDock.views": "右侧工作台视图",
|
||||
"rightDock.overview": "概览",
|
||||
|
||||
// 话题栏
|
||||
"topicBar.renameSession": "重命名会话",
|
||||
"topicBar.more": "更多",
|
||||
|
||||
// 范围标签
|
||||
"scope.global": "范围:全局",
|
||||
"scope.project": "项目 · {name}",
|
||||
|
||||
// 工作区面板
|
||||
"workspace.title": "工作区",
|
||||
"workspace.open": "打开工作区面板",
|
||||
"workspace.close": "关闭工作区面板",
|
||||
"workspace.minimize": "最小化工作区面板",
|
||||
"workspace.maximize": "最大化工作区面板",
|
||||
"workspace.restore": "还原工作区面板",
|
||||
"workspace.resizePanel": "调整工作区面板宽度",
|
||||
"workspace.hideTree": "隐藏文件树",
|
||||
"workspace.showTree": "显示文件树",
|
||||
"workspace.resizeTree": "调整文件树宽度",
|
||||
"workspace.newTab": "打开其他文件",
|
||||
"workspace.closeTab": "关闭标签",
|
||||
"workspace.currentFile": "当前文件",
|
||||
"workspace.recentFiles": "最近文件",
|
||||
"workspace.closePreview": "关闭预览",
|
||||
"workspace.filter": "筛选文件…",
|
||||
"workspace.filterChanges": "筛选改动…",
|
||||
"workspace.noFile": "未选择文件",
|
||||
"workspace.pickFile": "从文件树中选择一个文件。",
|
||||
"workspace.loading": "正在加载文件…",
|
||||
@@ -68,7 +91,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"workspace.viewMode": "工作区视图",
|
||||
"workspace.filesTab": "文件",
|
||||
"workspace.changedTab": "改动",
|
||||
"workspace.refreshChanges": "刷新改动文件",
|
||||
"workspace.treeMenu": "文件树操作",
|
||||
"workspace.refreshTree": "刷新文件树",
|
||||
"workspace.refreshChanges": "刷新改动",
|
||||
"workspace.loadingChanges": "正在加载改动…",
|
||||
"workspace.noChanges": "暂无改动文件",
|
||||
"workspace.gitUnavailable": "当前工作区不可读取 Git 状态。",
|
||||
@@ -107,6 +132,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.skillScopeProject": "项目",
|
||||
"caps.skillScopeCustom": "自定义",
|
||||
"caps.skillScopeGlobal": "全局",
|
||||
"caps.skillDisabled": "已停用",
|
||||
"caps.disableSkill": "停用技能",
|
||||
"caps.enableSkill": "启用技能",
|
||||
"caps.subagent": "子代理",
|
||||
"caps.addServer": "+ 添加服务器",
|
||||
"caps.add": "添加",
|
||||
@@ -141,6 +169,13 @@ export const zh: Record<DictKey, string> = {
|
||||
"caps.reauthorize": "重新授权",
|
||||
"caps.checkCommand": "检查命令",
|
||||
"caps.builtIn": "内置",
|
||||
"caps.auth": "授权",
|
||||
"caps.authRequired": "需要授权",
|
||||
"caps.authRequiredSummary": "该 MCP 服务器需要完成授权后才能连接。",
|
||||
"caps.authPossible": "可能需要授权",
|
||||
"caps.authPossibleShort": "可能需要授权",
|
||||
"caps.clearAuth": "清除授权",
|
||||
"caps.confirmClearAuth": "确认清除",
|
||||
"caps.codegraphWarming": "codegraph:内置代码智能仍在冷启动或准备项目索引,稍后重试即可",
|
||||
"caps.deferred": "已启用,用到该 MCP 时连接",
|
||||
"caps.initializing": "正在连接 MCP,工具可用后会自动出现",
|
||||
@@ -195,18 +230,21 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.planModeOn": "计划模式已开",
|
||||
"composer.planHint": "shift+tab",
|
||||
"composer.planHintExit": "shift+tab 退出",
|
||||
"composer.modeNormal": "普通",
|
||||
"composer.modePlan": "计划模式",
|
||||
"composer.modeNormal": "auto",
|
||||
"composer.modePlan": "plan",
|
||||
"composer.modeYolo": "YOLO",
|
||||
"composer.modeHint": "shift+tab",
|
||||
"composer.modeTitle": "切换模式(shift+tab):普通 → 计划 → YOLO(全部自动批准)",
|
||||
"composer.modeTitle": "切换运行模式(shift+tab):auto → plan → yolo",
|
||||
"composer.enterPlanTitle": "进入计划模式(shift+tab)—— 只读;先给出计划再动手",
|
||||
"composer.exitPlanTitle": "退出计划模式(shift+tab)",
|
||||
"composer.searchProjects": "搜索项目",
|
||||
"composer.noProjectMatches": "没有匹配的项目",
|
||||
"composer.addProject": "添加新项目",
|
||||
"composer.removeProject": "移除项目",
|
||||
"composer.removeCurrentProjectDisabled": "不能移除当前项目",
|
||||
"composer.send": "发送(Enter)",
|
||||
"composer.stop": "停止(Esc)",
|
||||
"composer.stopShort": "停止",
|
||||
"composer.pastedLabel": "[已粘贴文本 #{id} · {lines} 行]",
|
||||
"composer.pastedShowPreview": "预览粘贴文本",
|
||||
"composer.pastedHidePreview": "收起粘贴文本预览",
|
||||
@@ -219,7 +257,8 @@ export const zh: Record<DictKey, string> = {
|
||||
|
||||
// 状态栏
|
||||
"status.connecting": "连接中…",
|
||||
"status.ctx": "{pct}% 上下文",
|
||||
"status.ctx": "上下文 {pct}%",
|
||||
"status.compact": "压缩 {pct}%",
|
||||
"status.plan": "计划",
|
||||
"status.switchModel": "切换模型",
|
||||
"status.noModels": "未配置任何模型",
|
||||
@@ -229,12 +268,18 @@ export const zh: Record<DictKey, string> = {
|
||||
"status.switchFolder": "{cwd}\n点击切换项目目录",
|
||||
"status.tokens": "tokens",
|
||||
"status.retrying": "正在重试 ({attempt}/{max})…",
|
||||
"status.cache": "缓存 {pct}%",
|
||||
"status.cache": "本次命中 {pct}%",
|
||||
"status.cacheAvg": "平均 {pct}%",
|
||||
"status.ctxUnknown": "上下文 -",
|
||||
"status.compactUnknown": "压缩 -",
|
||||
"status.cost": "本次 {amount}",
|
||||
"status.balanceTitle": "钱包余额",
|
||||
"status.balance": "余额 {amount}",
|
||||
"status.spendTitle": "本会话花费",
|
||||
"status.jobs": "{n} 个运行中",
|
||||
"status.jobsCount": "任务 {n}",
|
||||
"status.jobsTitle": "后台作业",
|
||||
"status.workspaceTitle": "当前工作区",
|
||||
"status.yolo": "YOLO",
|
||||
"status.yoloTitle": "YOLO —— 本会话正在跳过所有批准",
|
||||
|
||||
@@ -266,13 +311,28 @@ export const zh: Record<DictKey, string> = {
|
||||
|
||||
// 历史抽屉
|
||||
"history.title": "历史",
|
||||
"history.trashTitle": "回收站",
|
||||
"history.empty": "还没有已保存的会话。",
|
||||
"history.trashEmpty": "回收站为空。",
|
||||
"history.trashHint": "删除的会话会先放在这里,可恢复或彻底删除。",
|
||||
"history.namePlaceholder": "会话名称…",
|
||||
"history.emptySession": "(空会话)",
|
||||
"history.current": "当前",
|
||||
"history.open": "已打开",
|
||||
"history.deleted": "已删除",
|
||||
"history.turnOne": "{n} 轮",
|
||||
"history.turnOther": "{n} 轮",
|
||||
"history.confirmDelete": "确认删除",
|
||||
"history.confirmPurge": "确认彻底删除",
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "确认清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "确认移入回收站",
|
||||
"history.restore": "恢复",
|
||||
"history.restoreSession": "恢复会话",
|
||||
"history.purge": "彻底删除",
|
||||
"history.purgeSession": "彻底删除…",
|
||||
"history.rename": "重命名",
|
||||
"history.preview": "预览",
|
||||
"history.previewEmpty": "这个会话没有可见消息。",
|
||||
@@ -281,6 +341,42 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.noResults": "没有匹配的会话。",
|
||||
"history.today": "今天",
|
||||
"history.yesterday": "昨天",
|
||||
"history.trashSessionActions": "回收站会话操作",
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目工作区",
|
||||
"projectTree.searchPlaceholder": "搜索项目或会话",
|
||||
"projectTree.newTopic": "新会话",
|
||||
"projectTree.renameTopic": "重命名会话",
|
||||
"projectTree.renameProject": "修改显示名称",
|
||||
"projectTree.projectHistory": "查看项目历史",
|
||||
"projectTree.removeProject": "移出侧边栏",
|
||||
"projectTree.confirmRemoveProject": "确认移出侧边栏",
|
||||
"projectTree.copyPath": "复制路径",
|
||||
"projectTree.revealInFinder": "在 Finder 中显示",
|
||||
"projectTree.revealInExplorer": "在文件资源管理器中显示",
|
||||
"projectTree.revealInFileManager": "在文件管理器中显示",
|
||||
"projectTree.topicActions": "会话操作",
|
||||
"projectTree.projectActions": "项目操作",
|
||||
"projectTree.colorDefault": "默认",
|
||||
"projectTree.colorRed": "红色",
|
||||
"projectTree.colorOrange": "橙色",
|
||||
"projectTree.colorAmber": "琥珀",
|
||||
"projectTree.colorGreen": "绿色",
|
||||
"projectTree.colorTeal": "青色",
|
||||
"projectTree.colorBlue": "蓝色",
|
||||
"projectTree.colorPurple": "紫色",
|
||||
"projectTree.colorPink": "粉色",
|
||||
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
|
||||
"projectTree.emptyNoProjects": "还没有项目",
|
||||
"projectTree.emptyNoProjectsDesc": "添加项目后,可以按项目管理会话、文件和上下文。",
|
||||
"projectTree.emptyAddProject": "添加新项目",
|
||||
"projectTree.emptyUseCurrentDir": "使用当前目录",
|
||||
"projectTree.emptyCurrentDir": "当前目录:{dir}",
|
||||
"projectTree.newTopicTooltip": "新建会话",
|
||||
"projectTree.addProjectTooltip": "添加新项目",
|
||||
|
||||
// 记忆抽屉
|
||||
"memory.title": "记忆",
|
||||
@@ -313,32 +409,42 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.title": "设置",
|
||||
"settings.loading": "加载中…",
|
||||
"settings.modelsProviders": "模型与模型服务",
|
||||
"settings.tab.general": "通用",
|
||||
"settings.tab.models": "模型",
|
||||
"settings.tab.providers": "模型服务",
|
||||
"settings.tab.network": "网络",
|
||||
"settings.tab.permissions": "权限",
|
||||
"settings.tab.sandbox": "沙箱",
|
||||
"settings.tab.agent": "智能体",
|
||||
"settings.tab.appearance": "外观",
|
||||
"settings.tab.updates": "更新",
|
||||
"settings.providerCount": "{n} 个服务",
|
||||
"settings.agentMeta": "温度 {temp} · 步数 {steps}",
|
||||
"settings.appearanceMeta": "主题 · 强调色 · 语言",
|
||||
"settings.appearanceMeta": "主题 · 强调色 · 字号",
|
||||
"settings.updatesMeta": "版本 · 配置",
|
||||
"settings.closeBehavior": "关闭窗口时",
|
||||
"settings.closeBehavior.background": "保持后台运行",
|
||||
"settings.closeBehavior.quit": "退出 Reasonix",
|
||||
"settings.manageProviders": "管理模型服务",
|
||||
"settings.activeProvider": "当前模型服务",
|
||||
"settings.plannerStatus": "规划模型",
|
||||
"settings.plannerEnabled": "已启用",
|
||||
"settings.plannerDisabled": "未启用",
|
||||
"settings.plannerStatus": "规划方式",
|
||||
"settings.plannerEnabled": "双模型",
|
||||
"settings.plannerDisabled": "单模型",
|
||||
"settings.plannerSingle": "单模型",
|
||||
"settings.plannerDual": "双模型",
|
||||
"settings.plannerSingleDetail": "{model} 负责规划与执行",
|
||||
"settings.plannerDualDetail": "{planner} 规划 · {executor} 执行",
|
||||
"settings.providers": "模型服务",
|
||||
"settings.availableModels": "可用模型",
|
||||
"settings.defaultModel": "默认模型",
|
||||
"settings.plannerModel": "规划模型",
|
||||
"settings.plannerNone": "(无 —— 单模型)",
|
||||
"settings.plannerModel": "独立规划模型",
|
||||
"settings.plannerNone": "使用当前模型(单模型)",
|
||||
"settings.autoPlan": "自动计划模式",
|
||||
"settings.autoPlan.off": "关闭",
|
||||
"settings.autoPlan.on": "开启",
|
||||
"settings.keySet": "已设密钥",
|
||||
"settings.noKey": "无密钥",
|
||||
"settings.cantDeleteDefault": "无法删除默认模型服务",
|
||||
"settings.deleteProvider": "删除模型服务",
|
||||
"settings.confirmDeleteProvider": "确认删除模型服务",
|
||||
"settings.addProvider": "+ 添加模型服务",
|
||||
"settings.providerName": "名称(如 deepseek-flash)",
|
||||
"settings.providerKind": "类型",
|
||||
@@ -351,6 +457,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerContextWindow": "上下文窗口",
|
||||
"settings.contextWindowPlaceholder": "token 数(0 = 模型服务默认值)",
|
||||
"settings.contextWindowHint": "该模型服务在上下文中保留的最大 token 数。填 0 表示使用模型服务默认值。",
|
||||
"settings.supportedEfforts": "支持的 effort 等级",
|
||||
"settings.supportedEffortsCustomPlaceholder": "自定义等级(按回车)",
|
||||
"settings.supportedEffortsHint": "勾选该模型服务在 /effort 命令中暴露的等级,或添加自定义名称。留空则使用内置默认(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
|
||||
"settings.defaultEffort": "默认 effort",
|
||||
"settings.defaultEffortAuto": "auto(使用内置默认)",
|
||||
"settings.defaultEffortHint": "在 /effort 为 auto 或未设置时使用。必须是上方等级之一;为空时回退到第一个等级(无选中时回退到内置默认)。",
|
||||
"settings.proxyMode": "代理模式",
|
||||
"settings.proxyMode.auto": "自动",
|
||||
"settings.proxyMode.env": "环境变量",
|
||||
@@ -365,7 +477,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.proxyUrlHint": "可选。填写后 proxy_url 会覆盖上面的服务器和端口。",
|
||||
"settings.noProxy": "不走代理",
|
||||
"settings.saveNetwork": "保存网络设置",
|
||||
"settings.setKey": "设置 {env}(→ .env)",
|
||||
"settings.setKey": "设置 {env}(全局保存)",
|
||||
"settings.saveKey": "保存密钥",
|
||||
"settings.permissions": "权限",
|
||||
"settings.writerMode": "写操作模式",
|
||||
@@ -383,13 +495,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
|
||||
"settings.workspaceRoot": "工作区根目录",
|
||||
"settings.workspaceDefault": "(默认:当前目录)",
|
||||
"settings.agent": "智能体",
|
||||
"settings.temperature": "采样温度",
|
||||
"settings.maxSteps": "最大步数",
|
||||
"settings.unlimited": "0 = 不限",
|
||||
"settings.systemPrompt": "系统提示词",
|
||||
"settings.saveAgent": "保存智能体设置",
|
||||
"settings.appearance": "外观与语言",
|
||||
"settings.appearance": "外观",
|
||||
"settings.theme": "主题",
|
||||
"settings.themeStyle": "强调色",
|
||||
"settings.themeAuto": "自动",
|
||||
@@ -398,6 +504,11 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.themeCurrent": "当前主题:{theme} / {style}",
|
||||
"settings.themeChanged": "已切换主题为 {theme} / {style}",
|
||||
"settings.themeUnknown": "未知主题:{name}",
|
||||
"settings.textSize": "字号",
|
||||
"settings.textSizeSmall": "小",
|
||||
"settings.textSizeDefault": "默认",
|
||||
"settings.textSizeLarge": "大",
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.language": "语言",
|
||||
"settings.langAuto": "自动(跟随系统)",
|
||||
"settings.config": "配置文件:{path}",
|
||||
@@ -405,6 +516,7 @@ export const zh: Record<DictKey, string> = {
|
||||
// 待办栏
|
||||
"todo.title": "待办",
|
||||
"todo.dismiss": "关闭待办列表",
|
||||
"todo.stale": "进度可能未同步",
|
||||
|
||||
// 斜杠菜单标签
|
||||
"slash.project": "项目",
|
||||
@@ -414,24 +526,47 @@ export const zh: Record<DictKey, string> = {
|
||||
// 助手消息
|
||||
"msg.thinking": "思考过程",
|
||||
"msg.copy": "复制",
|
||||
"questionNav.label": "问题导航",
|
||||
"questionNav.progress": "问题 {current} / {total}",
|
||||
"questionNav.jump": "跳转到问题 {n}",
|
||||
"questionNav.hint": "点击跳转到这次提问",
|
||||
"compaction.working": "正在压缩对话…",
|
||||
"compaction.title": "上下文已压缩",
|
||||
"compaction.messages": "{n} 条消息",
|
||||
"compaction.showSummary": "展开摘要",
|
||||
"compaction.hideSummary": "收起摘要",
|
||||
"rewind.label": "回滚 / 分叉 / 摘要…",
|
||||
"rewind.both": "代码 + 对话",
|
||||
"rewind.conversation": "仅对话",
|
||||
"rewind.code": "仅代码",
|
||||
"rewind.fork": "从这里分叉",
|
||||
"rewind.summFrom": "总结后续内容",
|
||||
"rewind.summUpto": "总结到这里",
|
||||
"rewind.label": "消息操作",
|
||||
"rewind.disabledRunning": "等待当前操作结束",
|
||||
"rewind.disabledNoCheckpoint": "这条消息没有可恢复点",
|
||||
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
|
||||
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
|
||||
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
|
||||
"rewind.filesChanged": "{count} 个文件",
|
||||
"rewind.both": "回滚代码和对话",
|
||||
"rewind.conversation": "仅回滚对话",
|
||||
"rewind.code": "仅回滚代码",
|
||||
"rewind.fork": "分叉会话",
|
||||
"rewind.summFrom": "总结这条之后",
|
||||
"rewind.summUpto": "总结这条之前",
|
||||
"rewind.confirmFork": "确认分叉会话",
|
||||
"rewind.confirmSummFrom": "确认总结这条之后",
|
||||
"rewind.confirmSummUpto": "确认总结这条之前",
|
||||
"rewind.confirmBoth": "确认回滚代码和对话",
|
||||
"rewind.confirmConversation": "确认回滚对话",
|
||||
"rewind.confirmCode": "确认回滚代码",
|
||||
"rewind.busyFork": "正在从这条消息分叉…",
|
||||
"rewind.busySummFrom": "正在总结这条消息之后的内容…",
|
||||
"rewind.busySummUpto": "正在总结到这条消息…",
|
||||
"rewind.busyConversation": "正在回滚对话…",
|
||||
"rewind.busyCode": "正在回滚代码…",
|
||||
"rewind.busyBoth": "正在回滚代码和对话…",
|
||||
"msg.copied": "已复制",
|
||||
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.truncated": "输出已截断",
|
||||
"tool.showAllLines": "显示全部 {n} 行",
|
||||
"tool.lineOne": "{n} 行",
|
||||
"tool.lineOther": "{n} 行",
|
||||
"tool.matchOne": "{n} 处匹配",
|
||||
@@ -466,16 +601,108 @@ export const zh: Record<DictKey, string> = {
|
||||
|
||||
// onboarding — first-run API-key overlay
|
||||
"onboarding.title": "连接 Reasonix",
|
||||
"onboarding.tagline": "粘贴一个 DeepSeek API key 即可开始。密钥仅存于本机的 .env,不会发往任何地方。",
|
||||
"onboarding.tagline": "粘贴一个 DeepSeek API key 即可开始。密钥仅存于本应用的本地凭据文件,不会发往任何地方。",
|
||||
"onboarding.inputLabel": "API 密钥",
|
||||
"onboarding.inputPlaceholder": "sk-…",
|
||||
"onboarding.submit": "连接并开始",
|
||||
"onboarding.validating": "校验中…",
|
||||
"onboarding.getKey": "如何获取 API key?",
|
||||
"onboarding.privacy": "仅保存在本应用的 .env",
|
||||
"onboarding.privacy": "仅保存在本应用的本地凭据文件",
|
||||
"onboarding.error.empty": "请先粘贴密钥。",
|
||||
"onboarding.error.invalid": "这个 key 无法使用 —— 检查一下是否已激活、是否开通了计费。",
|
||||
"onboarding.error.network": "无法连接 DeepSeek —— 检查网络后再试。",
|
||||
"onboarding.error.unknown": "出错啦:{msg}",
|
||||
"onboarding.skip": "稍后设置",
|
||||
|
||||
// 上下文面板
|
||||
"context.overview": "当前会话概览",
|
||||
"context.scopeGlobal": "范围:全局",
|
||||
"context.scopeProject": "项目 · {name}",
|
||||
"context.sessionChanges": "本会话变更",
|
||||
"context.referencedFiles": "依据文件",
|
||||
"context.noChanges": "当前会话还没有变更文件",
|
||||
"context.noReads": "当前会话还没有读取文件",
|
||||
"context.filterChanges": "筛选本会话变更文件...",
|
||||
"context.filterReads": "筛选依据文件...",
|
||||
"context.changedNote": "当前会话关联 {count} 条变更记录",
|
||||
"context.readNote": "当前会话读取过 {count} 条文件记录",
|
||||
"context.changedMeta": "{count} 条变更记录",
|
||||
"context.readMeta": "{count} 条读取记录",
|
||||
"context.viewAll": "查看全部",
|
||||
"context.health": "上下文状态",
|
||||
"context.healthGood": "上下文状态正常",
|
||||
"context.healthGoodBody": "用量、依据文件和本会话变更保持可追踪。",
|
||||
"context.healthNearLimit": "上下文接近上限",
|
||||
"context.healthNearLimitBody": "已使用 {pct}%,优先保留关键依据并考虑压缩。",
|
||||
"context.healthManyFiles": "依据文件较多",
|
||||
"context.healthManyFilesBody": "已读取 {count} 条文件记录,可查看依据文件确认是否仍相关。",
|
||||
"context.healthLowCache": "缓存命中偏低",
|
||||
"context.healthLowCacheBody": "当前缓存命中 {pct}%,后续长任务可关注上下文稳定性。",
|
||||
"context.prompt": "Prompt",
|
||||
"context.completion": "Completion",
|
||||
"context.reasoning": "Reasoning",
|
||||
"context.other": "Other",
|
||||
"context.total": "Total",
|
||||
"context.cacheHit": "缓存命中",
|
||||
"context.sessionCost": "本次费用",
|
||||
"context.requests": "请求数",
|
||||
"context.time": "耗时",
|
||||
"context.durationSeconds": "{seconds}秒",
|
||||
"context.durationMinutesSeconds": "{minutes}分{seconds}秒",
|
||||
|
||||
// 崩溃兜底页
|
||||
"crash.title": "Reasonix 遇到错误 —— 请截图发送",
|
||||
"crash.copy": "复制",
|
||||
|
||||
// 模拟/演示种子数据(仅浏览器开发模式)
|
||||
"mock.sessionFixLogin": "fix the login bug in auth.go",
|
||||
"mock.sessionRefactor": "refactor the payment module",
|
||||
"mock.sessionReadme": "write the README and badges",
|
||||
"mock.sessionPlugin": "explain the plugin host design",
|
||||
"mock.trashDevStandardTitle": "00 制定项目开发规范",
|
||||
"mock.trashDevStandardPreview": "整理项目开发规范,包含目录结构、分支约定和提交要求。",
|
||||
"mock.trashP3aTitle": "20260521 p3a P&D",
|
||||
"mock.trashP3aPreview": "复盘 p3a P&D 需求范围,记录遗留风险和下轮处理建议。",
|
||||
"mock.trashGlobalProductTitle": "产品通用规范",
|
||||
"mock.trashGlobalProductPreview": "沉淀跨项目通用产品规则,包含验收、灰度和异常处理。",
|
||||
"mock.projectJoyquantDb": "joyquant-db",
|
||||
"mock.projectJoyquantSys": "joyquant-sys",
|
||||
"mock.topicDevStandard": "00 制定项目开发规范",
|
||||
"mock.topicDbMaint": "01 JoyQuant DB项目维护",
|
||||
"mock.topicEnv": "项目环境uv和Python运行问题",
|
||||
"mock.topicP3b": "20260523 p3b P&D",
|
||||
"mock.topicP3a": "20260521 p3a P&D",
|
||||
"mock.topicHotfix": "20260522 post-p3-hotfix P&D",
|
||||
"mock.topicSysCoord": "01 JoyQuant-SYS 项目总协调",
|
||||
"mock.topicSysStandard": "00 制定项目开发规范",
|
||||
"mock.topicProduct": "产品通用规范",
|
||||
"mock.topicAi": "AI 工程化最佳实践",
|
||||
"mock.topicLab": "临时探索与实验",
|
||||
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
|
||||
"mock.askQ1Header": "处理方向",
|
||||
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
|
||||
"mock.askQ1Opt1Label": "git stash 后 pull",
|
||||
"mock.askQ1Opt1Desc": "用 git stash 暂存本地修改,拉取最新代码后再恢复",
|
||||
"mock.askQ1Opt2Label": "丢弃本地修改后 pull",
|
||||
"mock.askQ1Opt2Desc": "放弃本地所有修改,强制与远端同步",
|
||||
"mock.askQ1Opt3Label": "另建分支保存改动",
|
||||
"mock.askQ1Opt3Desc": "先创建分支把本地改动保存起来,再拉取主分支",
|
||||
"mock.askQ2Header": "Reasonix 构建",
|
||||
"mock.askQ2Prompt": "对于 reasonix 二进制缺失的问题,你想怎么做?",
|
||||
"mock.askQ2Opt1Label": "先查文档",
|
||||
"mock.askQ2Opt1Desc": "查看 README / 构建文档来确定正确的构建命令",
|
||||
"mock.askQ2Opt2Label": "看构建配置",
|
||||
"mock.askQ2Opt2Desc": "查看 desktop/wails.json 与 main.go 来推断入口",
|
||||
"mock.askQ2Opt3Label": "我先帮你尝试构建",
|
||||
"mock.askQ2Opt3Desc": "先处理 git 冲突,然后尝试本地构建并汇报结果",
|
||||
"mock.todo1": "梳理当前前端结构",
|
||||
"mock.todo2": "优化审批与 Ask 决策条布局",
|
||||
"mock.todo2ActiveForm": "正在调整决策条布局",
|
||||
"mock.todo3": "验证三种预览状态",
|
||||
"mock.newSession": "新的会话",
|
||||
"mock.memoryBody": "# 用户记忆\n\n始终用中文回复。",
|
||||
"mock.changedFile1Prompt": "更新规范草案 v0.3",
|
||||
"mock.changedFile2Prompt": "记录项目上下文",
|
||||
"mock.changedFile1Path": "docs/dev-standard.md",
|
||||
"mock.changedFile2Path": ".reasonix/project.md",
|
||||
};
|
||||
@@ -4,11 +4,33 @@ import App from "./App";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { installGlobalCrashHandlers } from "./lib/crash";
|
||||
import { LocaleProvider } from "./lib/i18n";
|
||||
import { initTextSize } from "./lib/textSize";
|
||||
import { initTheme } from "./lib/theme";
|
||||
import "./styles.css";
|
||||
|
||||
// Apply the saved appearance (auto/light/dark) before the first paint.
|
||||
initTheme();
|
||||
initTextSize();
|
||||
|
||||
// Pre-warm font fallback stacks so the first frame doesn't flicker between the
|
||||
// browser default font and the app's configured typeface. Inserting a hidden span
|
||||
// with CJK + emoji + math glyphs forces the OS font subsystem to resolve and
|
||||
// cache the fallback chains before React mounts.
|
||||
function prewarmFontFallbacks() {
|
||||
const span = document.createElement("span");
|
||||
span.style.cssText = "position:absolute;visibility:hidden;font-size:1px;pointer-events:none";
|
||||
span.textContent = "中文日本語한국어 математика 😀🎉✓⚠∑∏∫";
|
||||
document.body.appendChild(span);
|
||||
// Force layout so the browser resolves font fallback chains.
|
||||
void span.offsetHeight;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
span.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
prewarmFontFallbacks();
|
||||
|
||||
installGlobalCrashHandlers();
|
||||
|
||||
// Inside the Wails shell, suppress the webview's default right-click menu — its
|
||||
|
||||
+3035
-698
File diff suppressed because it is too large.
Load diff
@@ -21,6 +21,37 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
target: "es2021",
|
||||
// Use terser for smaller output (esbuild is faster to build but produces
|
||||
// larger bundles). Disabled for dev builds via the default.
|
||||
minify: "terser",
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true, // strip console.log in production
|
||||
passes: 2, // two compression passes for better tree-shaking
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Manual chunk splitting: keep the heavy markdown/math/code pipeline
|
||||
// in a separate chunk so it can be cached independently from the
|
||||
// app shell. The vendor chunk splits react+react-dom (stable, rarely
|
||||
// changes) from the markdown stack (changes more often).
|
||||
manualChunks: {
|
||||
"vendor-react": ["react", "react-dom"],
|
||||
"vendor-markdown": [
|
||||
"react-markdown",
|
||||
"remark-gfm",
|
||||
"remark-math",
|
||||
"rehype-katex",
|
||||
"katex",
|
||||
],
|
||||
"vendor-highlight": ["highlight.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
// Raise the warning limit — the markdown vendor chunk is legitimately large
|
||||
// (katex alone is ~300KB). The manual split ensures it's cached separately.
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
server: {
|
||||
// Bind IPv4 — unset host listens on ::1, and the Wails dev proxy's [::1]
|
||||
|
||||
+3
-3
@@ -13,20 +13,20 @@ require reasonix v0.0.0
|
||||
|
||||
require (
|
||||
aead.dev/minisign v0.3.0
|
||||
fyne.io/systray v1.12.1
|
||||
github.com/godbus/dbus/v5 v5.2.2
|
||||
github.com/minio/selfupdate v0.6.0
|
||||
github.com/wailsapp/wails/v2 v2.12.0
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
golang.org/x/mod v0.36.0
|
||||
golang.org/x/sys v0.45.0
|
||||
golang.org/x/text v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
|
||||
aead.dev/minisign v0.3.0 h1:8Xafzy5PEVZqYDNP60yJHARlW1eOQtsKNp/Ph2c0vRA=
|
||||
aead.dev/minisign v0.3.0/go.mod h1:NLvG3Uoq3skkRMDuc3YHpWUTMTrSExqm+Ij73W13F6Y=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
|
||||
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
|
||||
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
@@ -74,8 +74,8 @@ github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+
|
||||
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
|
||||
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
@@ -61,3 +65,57 @@ func TestPreviewSessionMessagesLoadsWithoutResuming(t *testing.T) {
|
||||
t.Fatalf("preview reasoning = %q, want saved reasoning", got[1].Reasoning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeSessionForTabTargetsSpecifiedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
|
||||
activePath := filepath.Join(dir, "active.jsonl")
|
||||
inactivePath := filepath.Join(dir, "inactive.jsonl")
|
||||
targetPath := filepath.Join(dir, "target.jsonl")
|
||||
writeHistoryTestSession(t, activePath, "active prompt")
|
||||
writeHistoryTestSession(t, inactivePath, "inactive prompt")
|
||||
writeHistoryTestSession(t, targetPath, "target prompt")
|
||||
|
||||
activeExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
|
||||
inactiveExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
|
||||
activeCtrl := control.New(control.Options{Executor: activeExec, SessionDir: dir, SessionPath: activePath, Label: "active"})
|
||||
inactiveCtrl := control.New(control.Options{Executor: inactiveExec, SessionDir: dir, SessionPath: inactivePath, Label: "inactive"})
|
||||
defer activeCtrl.Close()
|
||||
defer inactiveCtrl.Close()
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"active": {ID: "active", Scope: "global", Ctrl: activeCtrl, Ready: true},
|
||||
"inactive": {ID: "inactive", Scope: "global", Ctrl: inactiveCtrl, Ready: true},
|
||||
},
|
||||
tabOrder: []string{"active", "inactive"},
|
||||
activeTabID: "active",
|
||||
}
|
||||
|
||||
got, err := app.ResumeSessionForTab("inactive", targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ResumeSessionForTab: %v", err)
|
||||
}
|
||||
if activeCtrl.SessionPath() != activePath {
|
||||
t.Fatalf("active tab session path = %q, want %q", activeCtrl.SessionPath(), activePath)
|
||||
}
|
||||
if inactiveCtrl.SessionPath() != targetPath {
|
||||
t.Fatalf("inactive tab session path = %q, want %q", inactiveCtrl.SessionPath(), targetPath)
|
||||
}
|
||||
if len(got) != 1 || got[0].Content != "target prompt" {
|
||||
t.Fatalf("resumed history = %+v, want target prompt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeHistoryTestSession(t *testing.T, path, prompt string) {
|
||||
t.Helper()
|
||||
session := agent.NewSession("")
|
||||
session.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
|
||||
if err := session.Save(path); err != nil {
|
||||
t.Fatalf("Save %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
+37
-24
@@ -39,20 +39,40 @@ var version = "dev"
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
// Restore saved window size, or fall back to the default.
|
||||
width, height := 1240, 720
|
||||
if saved, ok := loadWindowState(); ok {
|
||||
if saved.Width > 0 {
|
||||
width = saved.Width
|
||||
}
|
||||
if saved.Height > 0 {
|
||||
height = saved.Height
|
||||
}
|
||||
}
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Reasonix",
|
||||
Width: 1240,
|
||||
Height: 720,
|
||||
Width: width,
|
||||
Height: height,
|
||||
MinWidth: 760,
|
||||
MinHeight: 480,
|
||||
// Match the dark UI shell so first paint (before CSS loads) doesn't flash
|
||||
// white — particularly visible on WebKitGTK. Uses the dark theme's --bg
|
||||
// colour (#1a1a2e = RGB 26,26,46).
|
||||
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
|
||||
AssetServer: &assetserver.Options{Assets: assets},
|
||||
OnStartup: app.startup,
|
||||
OnShutdown: app.shutdown,
|
||||
Bind: []any{app},
|
||||
// Match the dark UI shell so the initial webview background doesn't flash
|
||||
// white before CSS loads — particularly visible on WebKitGTK.
|
||||
BackgroundColour: &options.RGBA{R: 26, G: 26, B: 46, A: 255},
|
||||
AssetServer: &assetserver.Options{Assets: assets},
|
||||
OnStartup: app.startup,
|
||||
OnDomReady: app.domReady,
|
||||
OnBeforeClose: app.beforeClose,
|
||||
OnShutdown: app.shutdown,
|
||||
Bind: []any{app},
|
||||
SingleInstanceLock: singleInstanceLock(app),
|
||||
|
||||
// Start hidden — domReady positions and shows the window after restoring
|
||||
// geometry, so the user never sees the default size/position flash.
|
||||
StartHidden: true,
|
||||
|
||||
// Native application menu (File > Settings, Edit, Window).
|
||||
Menu: app.createAppMenu(),
|
||||
|
||||
// Native OS file drops: the webview withholds dropped files' paths from the
|
||||
// HTML drop event, so the frontend (composer) reads them via runtime.OnFileDrop
|
||||
@@ -63,22 +83,15 @@ func main() {
|
||||
Mac: &mac.Options{
|
||||
// Inset traffic-lights over a frameless-feeling header; the frontend
|
||||
// leaves a drag region at the top (CSS --wails-draggable).
|
||||
TitleBar: mac.TitleBarHiddenInset(),
|
||||
Appearance: mac.NSAppearanceNameDarkAqua,
|
||||
TitleBar: mac.TitleBarHiddenInset(),
|
||||
// Follow the OS appearance so the title bar matches light/dark system
|
||||
// preference instead of being locked to dark.
|
||||
Appearance: mac.DefaultAppearance,
|
||||
},
|
||||
Windows: &windows.Options{
|
||||
// Paint the OS title bar with the app's dark shell colour instead of the
|
||||
// default light caption that clashed against the dark UI on light-mode
|
||||
// Windows (#2793). Both modes use the shell colour so it always blends.
|
||||
Theme: windows.Dark,
|
||||
CustomTheme: &windows.ThemeSettings{
|
||||
DarkModeTitleBar: windows.RGB(26, 26, 46),
|
||||
DarkModeTitleText: windows.RGB(228, 228, 238),
|
||||
DarkModeBorder: windows.RGB(26, 26, 46),
|
||||
LightModeTitleBar: windows.RGB(26, 26, 46),
|
||||
LightModeTitleText: windows.RGB(228, 228, 238),
|
||||
LightModeBorder: windows.RGB(26, 26, 46),
|
||||
},
|
||||
// Follow the OS theme so the title bar matches light/dark system
|
||||
// preference instead of being locked to dark.
|
||||
Theme: windows.SystemDefault,
|
||||
},
|
||||
Linux: &linux.Options{
|
||||
ProgramName: "Reasonix",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
goruntime "runtime"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/menu"
|
||||
"github.com/wailsapp/wails/v2/pkg/menu/keys"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// createAppMenu builds the native application menu bar. macOS only: it's the
|
||||
// platform convention there, and the Edit menu's standard roles are what make
|
||||
// Cmd+C/V work in the webview. On Windows/Linux a menu bar renders as a stray
|
||||
// in-window "File" strip (the Edit/Window mac roles don't show), so return nil.
|
||||
func (a *App) createAppMenu() *menu.Menu {
|
||||
if goruntime.GOOS != "darwin" {
|
||||
return nil
|
||||
}
|
||||
|
||||
m := menu.NewMenu()
|
||||
|
||||
m.Append(menu.AppMenu())
|
||||
|
||||
fileMenu := m.AddSubmenu("File")
|
||||
fileMenu.AddText("Settings", keys.CmdOrCtrl(","), func(_ *menu.CallbackData) {
|
||||
if a.ctx != nil {
|
||||
runtime.EventsEmit(a.ctx, "app:open-settings")
|
||||
}
|
||||
})
|
||||
fileMenu.AddText("Show Reasonix", nil, func(_ *menu.CallbackData) {
|
||||
a.showMainWindow()
|
||||
})
|
||||
fileMenu.AddText("Quit Reasonix", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
|
||||
a.quitApp()
|
||||
})
|
||||
m.Append(menu.EditMenu())
|
||||
m.Append(menu.WindowMenu())
|
||||
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
agenttest "reasonix/internal/agent/testutil"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type desktopCountingTool struct {
|
||||
name string
|
||||
readOnly bool
|
||||
calls *int32
|
||||
}
|
||||
|
||||
func (t desktopCountingTool) Name() string { return t.name }
|
||||
func (t desktopCountingTool) Description() string { return "test tool" }
|
||||
func (t desktopCountingTool) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"command":{"type":"string"}}}`)
|
||||
}
|
||||
func (t desktopCountingTool) ReadOnly() bool { return t.readOnly }
|
||||
func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string, error) {
|
||||
atomic.AddInt32(t.calls, 1)
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
var calls int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
|
||||
args := `{"command":"python -c \"with open('prompt.txt', 'w') as f: f.write('hello')\""}`
|
||||
prov := agenttest.NewMock("scripted-desktop",
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: args}}},
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c2", Name: "bash", Arguments: args}}},
|
||||
agenttest.Turn{ToolCalls: []provider.ToolCall{{ID: "c3", Name: "bash", Arguments: args}}},
|
||||
agenttest.Turn{Text: "done"},
|
||||
)
|
||||
events := make(chan event.Event, 32)
|
||||
sink := event.FuncSink(func(e event.Event) { events <- e })
|
||||
ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, sink)
|
||||
ctrl := control.New(control.Options{Runner: ag, Executor: ag, Sink: sink})
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "scripted-desktop")
|
||||
|
||||
app.SubmitToTab("test", "update the prompt file")
|
||||
|
||||
var results []event.Event
|
||||
deadline := time.After(5 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Kind == event.ToolResult {
|
||||
results = append(results, e)
|
||||
}
|
||||
if e.Kind == event.TurnDone {
|
||||
if e.Err != nil {
|
||||
t.Fatalf("turn failed: %v", e.Err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("bash executed %d times, want 2 before the repeat guard blocks", got)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("tool results = %d, want 3", len(results))
|
||||
}
|
||||
last := results[len(results)-1].Tool.Output
|
||||
if !strings.Contains(last, "[loop guard]") || !strings.Contains(last, "edit_file") {
|
||||
t.Fatalf("third repeated write should nudge the model to change tools, got %q", last)
|
||||
}
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("timed out waiting for desktop turn to finish")
|
||||
}
|
||||
}
|
||||
}
|
||||
+181
-6
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
@@ -24,9 +25,12 @@ var errActiveSession = errors.New("can't delete the session you're in — start
|
||||
|
||||
const sessionTitlesFile = ".titles.json"
|
||||
const sessionDisplayFile = ".display.json"
|
||||
const sessionTrashDir = ".trash"
|
||||
const sessionTrashMetaFile = ".trash-meta.json"
|
||||
|
||||
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
|
||||
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
|
||||
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
|
||||
|
||||
// loadSessionTitles reads the basename→title map (missing/corrupt → empty).
|
||||
func loadSessionTitles(dir string) map[string]string {
|
||||
@@ -81,23 +85,133 @@ func setSessionTitle(dir, sessionPath, title string) error {
|
||||
return saveSessionTitles(dir, m)
|
||||
}
|
||||
|
||||
// deleteSessionFile removes a session's .jsonl and its sidecar state.
|
||||
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
|
||||
// trash. Title/display sidecars stay in place so trash previews and restores can
|
||||
// preserve the user's labels.
|
||||
func deleteSessionFile(dir, sessionPath string) error {
|
||||
sessionPath, key, err := validateSessionPath(dir, sessionPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeSessionArtifacts(dir, sessionPath, key)
|
||||
return trashSessionArtifacts(dir, sessionPath, key)
|
||||
}
|
||||
|
||||
func removeSessionArtifacts(dir, sessionPath, key string) error {
|
||||
if err := os.Remove(sessionPath); err != nil && !os.IsNotExist(err) {
|
||||
type trashedSessionMeta struct {
|
||||
Key string `json:"key"`
|
||||
DeletedAt int64 `json:"deletedAt"`
|
||||
}
|
||||
|
||||
func trashSessionArtifacts(dir, sessionPath, key string) error {
|
||||
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(sessionPath + ".meta"); err != nil && !os.IsNotExist(err) {
|
||||
itemDir := filepath.Join(sessionTrashPath(dir), key)
|
||||
if _, err := os.Stat(itemDir); err == nil {
|
||||
return fmt.Errorf("session already exists in trash: %s", key)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(strings.TrimSuffix(sessionPath, ".jsonl") + ".ckpt"); err != nil {
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(sessionPath, filepath.Join(itemDir, key)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(sessionPath+".meta", filepath.Join(itemDir, key+".meta")); err != nil {
|
||||
return err
|
||||
}
|
||||
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
|
||||
if err := movePathIfExists(strings.TrimSuffix(sessionPath, ".jsonl")+".ckpt", filepath.Join(itemDir, ckptName)); err != nil {
|
||||
return err
|
||||
}
|
||||
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
|
||||
b, err := json.MarshalIndent(meta, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listTrashedSessionFiles(dir string) ([]string, error) {
|
||||
root := sessionTrashPath(dir)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
paths := []string{}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
key := e.Name()
|
||||
if filepath.Ext(key) != ".jsonl" || filepath.Base(key) != key {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, key, key)
|
||||
validPath, _, _, err := validateTrashedSessionPath(dir, path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info, err := os.Stat(validPath); err == nil && !info.IsDir() {
|
||||
paths = append(paths, validPath)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func trashedSessionDeletedAt(path string) int64 {
|
||||
b, err := os.ReadFile(filepath.Join(filepath.Dir(path), sessionTrashMetaFile))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
var meta trashedSessionMeta
|
||||
if err := json.Unmarshal(b, &meta); err != nil {
|
||||
return 0
|
||||
}
|
||||
return meta.DeletedAt
|
||||
}
|
||||
|
||||
func restoreTrashedSessionFile(dir, path string) error {
|
||||
trashPath, key, itemDir, err := validateTrashedSessionPath(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dir, key)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return fmt.Errorf("session already exists: %s", key)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(trashPath, target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := movePathIfExists(trashPath+".meta", target+".meta"); err != nil {
|
||||
return err
|
||||
}
|
||||
ckptName := strings.TrimSuffix(key, ".jsonl") + ".ckpt"
|
||||
if err := movePathIfExists(filepath.Join(itemDir, ckptName), filepath.Join(dir, ckptName)); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(itemDir)
|
||||
}
|
||||
|
||||
func purgeTrashedSessionFile(dir, path string) error {
|
||||
_, key, itemDir, err := validateTrashedSessionPath(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(itemDir); err != nil {
|
||||
return err
|
||||
}
|
||||
m := loadSessionTitles(dir)
|
||||
@@ -116,6 +230,18 @@ func removeSessionArtifacts(dir, sessionPath, key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func movePathIfExists(src, dst string) error {
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(src, dst)
|
||||
}
|
||||
|
||||
func validateSessionPath(dir, sessionPath string) (string, string, error) {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return "", "", fmt.Errorf("empty session path")
|
||||
@@ -161,6 +287,55 @@ func validateSessionPath(dir, sessionPath string) (string, string, error) {
|
||||
return absPath, filepath.Base(absPath), nil
|
||||
}
|
||||
|
||||
func validateTrashedSessionPath(dir, sessionPath string) (string, string, string, error) {
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return "", "", "", fmt.Errorf("empty session path")
|
||||
}
|
||||
root, err := filepath.Abs(sessionTrashPath(dir))
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
path := sessionPath
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(root, path)
|
||||
}
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if filepath.Ext(absPath) != ".jsonl" {
|
||||
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
rel, err := filepath.Rel(root, absPath)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
||||
return "", "", "", fmt.Errorf("session path outside trash dir: %s", sessionPath)
|
||||
}
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) != 2 || parts[0] != parts[1] {
|
||||
return "", "", "", fmt.Errorf("invalid trash session path: %s", sessionPath)
|
||||
}
|
||||
if info, err := os.Lstat(absPath); err == nil {
|
||||
if info.IsDir() {
|
||||
return "", "", "", fmt.Errorf("not a session file: %s", sessionPath)
|
||||
}
|
||||
realRoot, dirErr := filepath.EvalSymlinks(root)
|
||||
if dirErr != nil {
|
||||
realRoot = root
|
||||
}
|
||||
realPath, err := filepath.EvalSymlinks(absPath)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
rel, err := filepath.Rel(realRoot, realPath)
|
||||
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." || filepath.IsAbs(rel) {
|
||||
return "", "", "", fmt.Errorf("session path escapes trash dir: %s", sessionPath)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", "", "", err
|
||||
}
|
||||
return absPath, filepath.Base(absPath), filepath.Dir(absPath), nil
|
||||
}
|
||||
|
||||
type sessionDisplayMap map[string]map[string]string
|
||||
|
||||
func messageDisplayKey(content string) string {
|
||||
|
||||
+120
-10
@@ -131,23 +131,36 @@ func TestDeleteSessionFile(t *testing.T) {
|
||||
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
// File should be gone.
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
||||
trashMetaPath := trashPath + ".meta"
|
||||
trashCkptDir := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.ckpt")
|
||||
|
||||
// File should be moved out of the active session list.
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Error("session file should be deleted")
|
||||
t.Error("session file should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(metaPath); !os.IsNotExist(err) {
|
||||
t.Error("session meta should be deleted")
|
||||
t.Error("session meta should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(ckptDir); !os.IsNotExist(err) {
|
||||
t.Error("session checkpoints should be deleted")
|
||||
t.Error("session checkpoints should be removed from active sessions")
|
||||
}
|
||||
// Title should be gone.
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("session file should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashMetaPath); err != nil {
|
||||
t.Fatalf("session meta should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(trashCkptDir); err != nil {
|
||||
t.Fatalf("session checkpoints should be in trash: %v", err)
|
||||
}
|
||||
// Title/display should be retained until permanent deletion.
|
||||
m := loadSessionTitles(dir)
|
||||
if _, ok := m["session.jsonl"]; ok {
|
||||
t.Error("title should be removed after delete")
|
||||
if m["session.jsonl"] != "My Title" {
|
||||
t.Errorf("title should be retained in trash, got %q", m["session.jsonl"])
|
||||
}
|
||||
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
||||
t.Errorf("display sidecar should be removed after delete, got %q", got)
|
||||
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "[Pasted text #1 · 5 lines]" {
|
||||
t.Errorf("display sidecar should be retained in trash, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +173,104 @@ func TestDeleteSessionFileNoTitle(t *testing.T) {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Error("session file should be deleted")
|
||||
t.Error("session file should be removed from active sessions")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, sessionTrashDir, "no-title.jsonl", "no-title.jsonl")); err != nil {
|
||||
t.Fatalf("session file should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreTrashedSessionFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sessionPath+".meta", []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckptDir := filepath.Join(dir, "session.ckpt")
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ckptDir, "1.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
||||
t.Fatalf("trash: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
||||
if err := restoreTrashedSessionFile(dir, trashPath); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("session file should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath + ".meta"); err != nil {
|
||||
t.Fatalf("session meta should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ckptDir); err != nil {
|
||||
t.Fatalf("session checkpoints should be restored: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("trash item should be removed after restore, stat err = %v", err)
|
||||
}
|
||||
if got := loadSessionTitles(dir)["session.jsonl"]; got != "My Title" {
|
||||
t.Fatalf("title should survive restore, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeTrashedSessionFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "session.jsonl")
|
||||
os.WriteFile(sessionPath, []byte("data"), 0o644)
|
||||
if err := setSessionTitle(dir, sessionPath, "My Title"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := recordSessionDisplay(dir, sessionPath, "expanded prompt", "[Pasted text #1 · 5 lines]"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deleteSessionFile(dir, sessionPath); err != nil {
|
||||
t.Fatalf("trash: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "session.jsonl", "session.jsonl")
|
||||
if err := purgeTrashedSessionFile(dir, trashPath); err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(trashPath)); !os.IsNotExist(err) {
|
||||
t.Fatalf("trash item should be removed after purge, stat err = %v", err)
|
||||
}
|
||||
if _, ok := loadSessionTitles(dir)["session.jsonl"]; ok {
|
||||
t.Fatal("title should be removed after purge")
|
||||
}
|
||||
if got := resolveSessionDisplay(dir, sessionPath, "expanded prompt"); got != "expanded prompt" {
|
||||
t.Fatalf("display sidecar should be removed after purge, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTrashedSessionFilesRejectsSymlinkEscape(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "outside.jsonl")
|
||||
os.WriteFile(outside, []byte("data"), 0o644)
|
||||
itemDir := filepath.Join(dir, sessionTrashDir, "outside.jsonl")
|
||||
if err := os.MkdirAll(itemDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(itemDir, "outside.jsonl")); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
|
||||
got, err := listTrashedSessionFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("list trash: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("trash listing should skip symlink escape, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+215
-51
@@ -2,8 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
@@ -22,15 +22,17 @@ import (
|
||||
// --- read ---
|
||||
|
||||
type ProviderView struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Models []string `json:"models"`
|
||||
Default string `json:"default"`
|
||||
APIKeyEnv string `json:"apiKeyEnv"`
|
||||
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
|
||||
BalanceURL string `json:"balanceUrl"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Models []string `json:"models"`
|
||||
Default string `json:"default"`
|
||||
APIKeyEnv string `json:"apiKeyEnv"`
|
||||
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
|
||||
BalanceURL string `json:"balanceUrl"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
SupportedEfforts []string `json:"supportedEfforts"`
|
||||
DefaultEffort string `json:"defaultEffort"`
|
||||
}
|
||||
|
||||
type PermissionsView struct {
|
||||
@@ -70,14 +72,19 @@ type AgentView struct {
|
||||
|
||||
// SettingsView is the whole Settings panel payload.
|
||||
type SettingsView struct {
|
||||
DefaultModel string `json:"defaultModel"`
|
||||
PlannerModel string `json:"plannerModel"`
|
||||
Providers []ProviderView `json:"providers"`
|
||||
Permissions PermissionsView `json:"permissions"`
|
||||
Sandbox SandboxView `json:"sandbox"`
|
||||
Network NetworkView `json:"network"`
|
||||
Agent AgentView `json:"agent"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
DefaultModel string `json:"defaultModel"`
|
||||
PlannerModel string `json:"plannerModel"`
|
||||
AutoPlan string `json:"autoPlan"`
|
||||
Providers []ProviderView `json:"providers"`
|
||||
Permissions PermissionsView `json:"permissions"`
|
||||
Sandbox SandboxView `json:"sandbox"`
|
||||
Network NetworkView `json:"network"`
|
||||
Agent AgentView `json:"agent"`
|
||||
DesktopLanguage string `json:"desktopLanguage"`
|
||||
DesktopTheme string `json:"desktopTheme"`
|
||||
DesktopThemeStyle string `json:"desktopThemeStyle"`
|
||||
CloseBehavior string `json:"closeBehavior"`
|
||||
ConfigPath string `json:"configPath"`
|
||||
// ProviderKinds lists the provider implementations the kernel actually
|
||||
// registered (provider.Kinds()), so the editor's "kind" picker offers only
|
||||
// kinds that resolve — selecting an unregistered one would fail the rebuild.
|
||||
@@ -96,7 +103,7 @@ func nonNil(s []string) []string {
|
||||
|
||||
// Settings returns the current configuration for the Settings panel.
|
||||
func (a *App) Settings() SettingsView {
|
||||
cfg, err := config.Load()
|
||||
cfg, cfgPath, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return SettingsView{
|
||||
Providers: []ProviderView{},
|
||||
@@ -107,9 +114,14 @@ func (a *App) Settings() SettingsView {
|
||||
Ask: []string{},
|
||||
Deny: []string{},
|
||||
},
|
||||
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
|
||||
Sandbox: SandboxView{Bash: "enforce", AllowWrite: []string{}},
|
||||
AutoPlan: "off",
|
||||
DesktopTheme: "dark",
|
||||
DesktopThemeStyle: "graphite",
|
||||
CloseBehavior: "background",
|
||||
}
|
||||
}
|
||||
ctrl := a.activeCtrl()
|
||||
bash := cfg.Sandbox.Bash
|
||||
if bash == "" {
|
||||
bash = "enforce"
|
||||
@@ -117,6 +129,7 @@ func (a *App) Settings() SettingsView {
|
||||
v := SettingsView{
|
||||
DefaultModel: cfg.DefaultModel,
|
||||
PlannerModel: cfg.Agent.PlannerModel,
|
||||
AutoPlan: desktopAutoPlanMode(cfg.Agent.AutoPlan),
|
||||
Providers: []ProviderView{},
|
||||
Permissions: PermissionsView{
|
||||
Mode: orDefault(cfg.Permissions.Mode, "ask"),
|
||||
@@ -140,20 +153,26 @@ func (a *App) Settings() SettingsView {
|
||||
Password: cfg.Network.Proxy.Password,
|
||||
},
|
||||
},
|
||||
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
|
||||
ConfigPath: config.SourcePath(),
|
||||
ProviderKinds: provider.Kinds(),
|
||||
Bypass: a.ctrl != nil && a.ctrl.Bypass(),
|
||||
Agent: AgentView{Temperature: cfg.Agent.Temperature, MaxSteps: cfg.Agent.MaxSteps, SystemPrompt: cfg.Agent.SystemPrompt},
|
||||
DesktopLanguage: cfg.DesktopLanguage(),
|
||||
DesktopTheme: cfg.DesktopTheme(),
|
||||
DesktopThemeStyle: cfg.DesktopThemeStyle(),
|
||||
CloseBehavior: cfg.DesktopCloseBehavior(),
|
||||
ConfigPath: cfgPath,
|
||||
ProviderKinds: nonNil(provider.Kinds()),
|
||||
Bypass: ctrl != nil && ctrl.Bypass(),
|
||||
}
|
||||
for i := range cfg.Providers {
|
||||
p := &cfg.Providers[i]
|
||||
v.Providers = append(v.Providers, ProviderView{
|
||||
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
|
||||
Models: nonNil(p.ModelList()), Default: p.DefaultModel(),
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
|
||||
BalanceURL: p.BalanceURL,
|
||||
ContextWindow: p.ContextWindow,
|
||||
APIKeyEnv: p.APIKeyEnv,
|
||||
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
|
||||
BalanceURL: p.BalanceURL,
|
||||
ContextWindow: p.ContextWindow,
|
||||
SupportedEfforts: nonNil(p.SupportedEfforts),
|
||||
DefaultEffort: p.DefaultEffort,
|
||||
})
|
||||
}
|
||||
return v
|
||||
@@ -173,11 +192,10 @@ func orDefault(s, def string) string {
|
||||
// keys are account-level, not per-project: writing them to the global config
|
||||
// rather than the cwd's reasonix.toml is what lets them survive a workspace switch.
|
||||
func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
|
||||
path := config.UserConfigPath()
|
||||
if path == "" {
|
||||
return fmt.Errorf("cannot resolve user config directory")
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := config.LoadForEdit(path)
|
||||
if err := mutate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -187,6 +205,65 @@ func (a *App) applyConfigChange(mutate func(*config.Config) error) error {
|
||||
return a.rebuild()
|
||||
}
|
||||
|
||||
func (a *App) applyConfigOnly(mutate func(*config.Config) error) error {
|
||||
cfg, path, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mutate(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return cfg.SaveTo(path)
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {
|
||||
userPath := config.UserConfigPath()
|
||||
if userPath == "" {
|
||||
return nil, "", fmt.Errorf("cannot resolve user config directory")
|
||||
}
|
||||
if _, err := os.Stat(userPath); err == nil {
|
||||
return config.LoadForEdit(userPath), userPath, nil
|
||||
}
|
||||
cfg := config.LoadForEdit(userPath)
|
||||
legacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())
|
||||
if legacyPath == "" || sameConfigPath(legacyPath, userPath) {
|
||||
return cfg, userPath, nil
|
||||
}
|
||||
legacyCfg := config.LoadForEdit(legacyPath)
|
||||
legacyCfg.ConfigVersion = config.Default().ConfigVersion
|
||||
return legacyCfg, userPath, nil
|
||||
}
|
||||
|
||||
func (a *App) activeWorkspaceRoot() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if tab := a.activeTabLocked(); tab != nil {
|
||||
return tab.WorkspaceRoot
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func projectConfigPathForRoot(root string) string {
|
||||
if strings.TrimSpace(root) == "" || root == "." {
|
||||
return "reasonix.toml"
|
||||
}
|
||||
return filepath.Join(root, "reasonix.toml")
|
||||
}
|
||||
|
||||
func sameConfigPath(a, b string) bool {
|
||||
a = strings.TrimSpace(a)
|
||||
b = strings.TrimSpace(b)
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
aAbs, aErr := filepath.Abs(a)
|
||||
bAbs, bErr := filepath.Abs(b)
|
||||
if aErr == nil && bErr == nil {
|
||||
return filepath.Clean(aAbs) == filepath.Clean(bAbs)
|
||||
}
|
||||
return filepath.Clean(a) == filepath.Clean(b)
|
||||
}
|
||||
|
||||
// rebuild tears down the controller and rebuilds it from the (just-changed)
|
||||
// config, carrying the conversation forward. It keeps the active model if it
|
||||
// still resolves; otherwise it falls back to the new default. Mirrors SetModel.
|
||||
@@ -194,15 +271,20 @@ func (a *App) rebuild() error {
|
||||
if a.ctx == nil {
|
||||
return nil
|
||||
}
|
||||
tab := a.activeTab()
|
||||
if tab == nil {
|
||||
return fmt.Errorf("no active tab")
|
||||
}
|
||||
var carried []provider.Message
|
||||
prevPath := ""
|
||||
if a.ctrl != nil {
|
||||
prevPath = a.ctrl.SessionPath()
|
||||
_ = a.ctrl.Snapshot()
|
||||
carried = a.ctrl.History()
|
||||
if tab.Ctrl != nil {
|
||||
prevPath = tab.Ctrl.SessionPath()
|
||||
_ = tab.Ctrl.Snapshot()
|
||||
carried = tab.Ctrl.History()
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
model := a.model
|
||||
if cfg, err := config.Load(); err == nil {
|
||||
model := tab.model
|
||||
if cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {
|
||||
if _, ok := cfg.ResolveModel(model); !ok {
|
||||
model = cfg.DefaultModel
|
||||
if e, ok := cfg.ResolveModel(model); ok {
|
||||
@@ -210,20 +292,31 @@ func (a *App) rebuild() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
ctrl, err := boot.Build(a.ctx, boot.Options{Model: model, RequireKey: false, Sink: a.sink, Stderr: io.Discard})
|
||||
ctrl, err := boot.Build(a.bootContext(), boot.Options{
|
||||
Model: model, RequireKey: false,
|
||||
Sink: tab.sink,
|
||||
WorkspaceRoot: tab.WorkspaceRoot,
|
||||
EffortOverride: cloneStringPtr(tab.effort),
|
||||
})
|
||||
if err != nil {
|
||||
a.startupErr = err.Error()
|
||||
a.mu.Lock()
|
||||
tab.StartupErr = err.Error()
|
||||
tab.Ready = true
|
||||
a.mu.Unlock()
|
||||
a.emitReady(a.ctx)
|
||||
return err
|
||||
}
|
||||
old := a.ctrl
|
||||
a.ctrl = ctrl
|
||||
a.model = model
|
||||
a.label = ctrl.Label()
|
||||
a.startupErr = ""
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
a.mu.Lock()
|
||||
tab.Ctrl = ctrl
|
||||
tab.model = model
|
||||
tab.Label = ctrl.Label()
|
||||
tab.StartupErr = ""
|
||||
tab.Ready = true
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
a.emitReady(a.ctx)
|
||||
ctrl.EnableInteractiveApproval()
|
||||
applyTabModeToController(ctrl, tab.mode)
|
||||
path := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())
|
||||
if len(carried) > 0 {
|
||||
carried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))
|
||||
@@ -264,8 +357,12 @@ func withFreshSystemPrompt(messages []provider.Message, system string) []provide
|
||||
|
||||
// SetDefaultModel sets the config default and switches the live model to it.
|
||||
func (a *App) SetDefaultModel(ref string) error {
|
||||
prev := a.model
|
||||
a.model = ref
|
||||
tab := a.activeTab()
|
||||
if tab == nil {
|
||||
return fmt.Errorf("no active tab")
|
||||
}
|
||||
prev := tab.model
|
||||
tab.model = ref
|
||||
if err := a.applyConfigChange(func(c *config.Config) error {
|
||||
if _, ok := c.ResolveModel(ref); !ok {
|
||||
return fmt.Errorf("unknown model %q", ref)
|
||||
@@ -273,7 +370,7 @@ func (a *App) SetDefaultModel(ref string) error {
|
||||
c.DefaultModel = ref
|
||||
return nil
|
||||
}); err != nil {
|
||||
a.model = prev
|
||||
tab.model = prev
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -292,6 +389,20 @@ func (a *App) SetPlannerModel(ref string) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SetAutoPlan updates the automatic plan-mode gate (off|on).
|
||||
func (a *App) SetAutoPlan(mode string) error {
|
||||
return a.applyConfigChange(func(c *config.Config) error { return c.SetAutoPlan(mode) })
|
||||
}
|
||||
|
||||
func desktopAutoPlanMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "on", "ask":
|
||||
return "on"
|
||||
default:
|
||||
return "off"
|
||||
}
|
||||
}
|
||||
|
||||
// SaveProvider adds or updates a provider. A single model fills `model`; several
|
||||
// fill `models` (with `default`). The shared key/endpoint live on the entry.
|
||||
func (a *App) SaveProvider(p ProviderView) error {
|
||||
@@ -299,6 +410,8 @@ func (a *App) SaveProvider(p ProviderView) error {
|
||||
e := config.ProviderEntry{
|
||||
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
|
||||
APIKeyEnv: p.APIKeyEnv, BalanceURL: strings.TrimSpace(p.BalanceURL), ContextWindow: p.ContextWindow,
|
||||
SupportedEfforts: p.SupportedEfforts,
|
||||
DefaultEffort: p.DefaultEffort,
|
||||
}
|
||||
if len(p.Models) > 0 {
|
||||
e.Model = p.Models[0] // also satisfies validateProvider's model requirement
|
||||
@@ -376,6 +489,57 @@ func (a *App) SetNetwork(n NetworkView) error {
|
||||
})
|
||||
}
|
||||
|
||||
// SetCloseBehavior updates desktop-only window close behavior without rebuilding
|
||||
// the active controller. It must stay out of provider-visible prompt/request data.
|
||||
func (a *App) SetCloseBehavior(mode string) error {
|
||||
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })
|
||||
}
|
||||
|
||||
// SetDesktopLanguage updates only the desktop UI language. It deliberately does
|
||||
// not touch config.language, which the CLI/model-facing runtime uses.
|
||||
func (a *App) SetDesktopLanguage(lang string) error {
|
||||
if err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopLanguage(lang) }); err != nil {
|
||||
return err
|
||||
}
|
||||
a.updateTrayLocale(lang)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTrayLocale mirrors the resolved desktop UI language into the native tray
|
||||
// menu. It is runtime-only; the persisted preference remains [desktop].language.
|
||||
func (a *App) SetTrayLocale(locale string) error {
|
||||
if locale != "zh" {
|
||||
locale = "en"
|
||||
}
|
||||
a.updateTrayLocale(locale)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDesktopAppearance updates only desktop theme preferences. It does not
|
||||
// rebuild the active controller and must stay out of provider-visible requests.
|
||||
func (a *App) SetDesktopAppearance(theme, style string) error {
|
||||
return a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })
|
||||
}
|
||||
|
||||
// MigrateDesktopPreferences imports old browser-local desktop preferences into
|
||||
// the user config once. Existing [desktop] values win so stale localStorage never
|
||||
// overwrites an explicit config edit.
|
||||
func (a *App) MigrateDesktopPreferences(language, theme, style string) error {
|
||||
return a.applyConfigOnly(func(c *config.Config) error {
|
||||
if strings.TrimSpace(c.Desktop.Language) == "" {
|
||||
if err := c.SetDesktopLanguage(language); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(c.Desktop.Theme) == "" && strings.TrimSpace(c.Desktop.ThemeStyle) == "" {
|
||||
if err := c.SetDesktopAppearance(theme, style); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetAgentParams updates sampling temperature, the optional max-steps guard, and
|
||||
// the base system prompt.
|
||||
func (a *App) SetAgentParams(temperature float64, maxSteps int, systemPrompt string) error {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import "github.com/wailsapp/wails/v2/pkg/options"
|
||||
|
||||
func singleInstanceLock(app *App) *options.SingleInstanceLock {
|
||||
return &options.SingleInstanceLock{
|
||||
UniqueId: singleInstanceID,
|
||||
OnSecondInstanceLaunch: func(options.SecondInstanceData) {
|
||||
app.secondInstanceLaunch()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
)
|
||||
|
||||
func TestSingleInstanceLockRestoresExistingInstance(t *testing.T) {
|
||||
app := NewApp()
|
||||
lock := singleInstanceLock(app)
|
||||
|
||||
if lock == nil {
|
||||
t.Fatal("singleInstanceLock returned nil")
|
||||
}
|
||||
if lock.UniqueId != singleInstanceID {
|
||||
t.Fatalf("UniqueId = %q, want %q", lock.UniqueId, singleInstanceID)
|
||||
}
|
||||
if lock.OnSecondInstanceLaunch == nil {
|
||||
t.Fatal("OnSecondInstanceLaunch should restore the existing window")
|
||||
}
|
||||
|
||||
lock.OnSecondInstanceLaunch(options.SecondInstanceData{})
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/skill"
|
||||
)
|
||||
|
||||
func TestNormalizeSkillPathDirectoryLayout(t *testing.T) {
|
||||
@@ -26,6 +28,7 @@ func TestSkillRootsViewCountsProjectSkills(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(project, ".reasonix", "skills")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
@@ -63,6 +66,7 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
project := t.TempDir()
|
||||
root := filepath.Join(home, "custom-skills")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
@@ -104,6 +108,44 @@ func TestSkillRootsViewMarksEnvConfiguredCustomRoot(t *testing.T) {
|
||||
t.Fatalf("custom skill root %q not found in %+v", root, roots)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesDisabledSkills(t *testing.T) {
|
||||
a := NewApp()
|
||||
a.setTestCtrl(control.New(control.Options{
|
||||
Skills: []skill.Skill{
|
||||
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
|
||||
},
|
||||
AllSkills: []skill.Skill{
|
||||
{Name: "explore", Description: "enabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
|
||||
{Name: "review", Description: "disabled", Scope: skill.ScopeBuiltin, RunAs: skill.RunSubagent},
|
||||
},
|
||||
}), "")
|
||||
defer a.activeCtrl().Close()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
||||
cfgPath := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(cfgPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfgPath, []byte("[skills]\ndisabled_skills = [\"review\"]\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view := a.Capabilities()
|
||||
states := map[string]bool{}
|
||||
for _, sk := range view.Skills {
|
||||
states[sk.Name] = sk.Enabled
|
||||
}
|
||||
if states["explore"] != true {
|
||||
t.Fatalf("explore should be enabled in capabilities: %+v", view.Skills)
|
||||
}
|
||||
enabled, ok := states["review"]
|
||||
if !ok || enabled {
|
||||
t.Fatalf("review should be disabled but present in capabilities: %+v", view.Skills)
|
||||
}
|
||||
}
|
||||
|
||||
func realTestPath(path string) string {
|
||||
if p, err := filepath.EvalSymlinks(path); err == nil {
|
||||
path = p
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
var systemQuitRequested atomic.Bool
|
||||
|
||||
func markSystemQuitRequested() {
|
||||
systemQuitRequested.Store(true)
|
||||
}
|
||||
|
||||
func consumeSystemQuitRequested() bool {
|
||||
return systemQuitRequested.Swap(false)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#cgo darwin LDFLAGS: -framework Cocoa
|
||||
void installReasonixSystemQuitHook(void);
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "sync"
|
||||
|
||||
var installSystemQuitHookOnce sync.Once
|
||||
|
||||
func installSystemQuitHook() {
|
||||
installSystemQuitHookOnce.Do(func() {
|
||||
C.installReasonixSystemQuitHook()
|
||||
})
|
||||
}
|
||||
|
||||
//export ReasonixMarkSystemQuit
|
||||
func ReasonixMarkSystemQuit() {
|
||||
markSystemQuitRequested()
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <objc/runtime.h>
|
||||
|
||||
extern void ReasonixMarkSystemQuit(void);
|
||||
|
||||
static NSApplicationTerminateReply (*originalApplicationShouldTerminate)(id, SEL, NSApplication *);
|
||||
static void (*originalWailsContextQuit)(id, SEL);
|
||||
|
||||
static NSApplicationTerminateReply reasonixApplicationShouldTerminate(id self, SEL _cmd, NSApplication *sender) {
|
||||
ReasonixMarkSystemQuit();
|
||||
if (originalApplicationShouldTerminate != NULL) {
|
||||
return originalApplicationShouldTerminate(self, _cmd, sender);
|
||||
}
|
||||
return NSTerminateNow;
|
||||
}
|
||||
|
||||
static void reasonixWailsContextQuit(id self, SEL _cmd) {
|
||||
ReasonixMarkSystemQuit();
|
||||
if (originalWailsContextQuit != NULL) {
|
||||
originalWailsContextQuit(self, _cmd);
|
||||
}
|
||||
}
|
||||
|
||||
void installReasonixSystemQuitHook(void) {
|
||||
Class appDelegate = NSClassFromString(@"AppDelegate");
|
||||
SEL selector = @selector(applicationShouldTerminate:);
|
||||
Method method = appDelegate == Nil ? NULL : class_getInstanceMethod(appDelegate, selector);
|
||||
if (method != NULL) {
|
||||
IMP replacement = (IMP)reasonixApplicationShouldTerminate;
|
||||
IMP previous = method_setImplementation(method, replacement);
|
||||
originalApplicationShouldTerminate = (NSApplicationTerminateReply (*)(id, SEL, NSApplication *))previous;
|
||||
}
|
||||
|
||||
Class wailsContext = NSClassFromString(@"WailsContext");
|
||||
SEL quitSelector = @selector(Quit);
|
||||
Method quitMethod = wailsContext == Nil ? NULL : class_getInstanceMethod(wailsContext, quitSelector);
|
||||
if (quitMethod != NULL) {
|
||||
IMP replacement = (IMP)reasonixWailsContextQuit;
|
||||
IMP previous = method_setImplementation(quitMethod, replacement);
|
||||
originalWailsContextQuit = (void (*)(id, SEL))previous;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
func installSystemQuitHook() {}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
func testTab(id, root string) *WorkspaceTab {
|
||||
return &WorkspaceTab{
|
||||
ID: id,
|
||||
Scope: "project",
|
||||
WorkspaceRoot: root,
|
||||
Ready: true,
|
||||
Ctrl: control.New(control.Options{Label: id}),
|
||||
model: "deepseek-flash/deepseek-v4-flash",
|
||||
mode: "normal",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEffortForTabIsTabLocal(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
rootA := t.TempDir()
|
||||
rootB := t.TempDir()
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
tabA := testTab("a", rootA)
|
||||
tabB := testTab("b", rootB)
|
||||
tabA.sink = &tabEventSink{tabID: tabA.ID, app: app}
|
||||
tabB.sink = &tabEventSink{tabID: tabB.ID, app: app}
|
||||
app.tabs = map[string]*WorkspaceTab{tabA.ID: tabA, tabB.ID: tabB}
|
||||
app.tabOrder = []string{tabA.ID, tabB.ID}
|
||||
app.activeTabID = tabA.ID
|
||||
defer func() {
|
||||
for _, tab := range app.tabs {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := app.SetEffortForTab(tabA.ID, "max"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
if got := app.EffortForTab(tabA.ID).Current; got != "max" {
|
||||
t.Fatalf("tab A effort = %q, want max", got)
|
||||
}
|
||||
if got := app.EffortForTab(tabB.ID).Current; got != "auto" {
|
||||
t.Fatalf("tab B effort = %q, want auto", got)
|
||||
}
|
||||
if tabB.effort != nil {
|
||||
t.Fatalf("tab B stored effort = %q, want nil", *tabB.effort)
|
||||
}
|
||||
body, err := os.ReadFile(userConfigPathForTest())
|
||||
if err == nil && strings.Contains(string(body), `effort`) {
|
||||
t.Fatalf("tab-local effort should not write provider config:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortForTabResolvesProjectProviderConfig(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
configBody := `default_model = "project-provider/deepseek-v4-flash"
|
||||
[[providers]]
|
||||
name = "project-provider"
|
||||
kind = "openai"
|
||||
base_url = "https://api.deepseek.com"
|
||||
model = "deepseek-v4-flash"
|
||||
api_key_env = "PROJECT_API_KEY"
|
||||
effort = "max"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(configBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
tab := testTab("project", projectRoot)
|
||||
tab.model = "project-provider/deepseek-v4-flash"
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.activeTabID = tab.ID
|
||||
defer tab.Ctrl.Close()
|
||||
|
||||
got := app.EffortForTab(tab.ID)
|
||||
if !got.Supported || got.Current != "max" {
|
||||
t.Fatalf("EffortForTab project config = %+v, want supported max", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTabsPersistsModelAndEffort(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
effort := "max"
|
||||
app := NewApp()
|
||||
tab := testTab("a", t.TempDir())
|
||||
tab.effort = &effort
|
||||
tab.model = "deepseek/deepseek-v4-pro"
|
||||
tab.mode = "plan"
|
||||
tab.Ctrl.SetPlanMode(true)
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
got := loadTabsFile()
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].Model != tab.model {
|
||||
t.Fatalf("saved model = %q, want %q", got.Tabs[0].Model, tab.model)
|
||||
}
|
||||
if got.Tabs[0].Effort == nil || *got.Tabs[0].Effort != effort {
|
||||
t.Fatalf("saved effort = %#v, want %q", got.Tabs[0].Effort, effort)
|
||||
}
|
||||
if got.Tabs[0].Mode != "plan" {
|
||||
t.Fatalf("saved mode = %q, want plan", got.Tabs[0].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveTabsDoesNotPersistYoloMode(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
tab := testTab("a", t.TempDir())
|
||||
tab.mode = "yolo"
|
||||
tab.Ctrl.SetBypass(true)
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
got := loadTabsFile()
|
||||
if len(got.Tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(got.Tabs))
|
||||
}
|
||||
if got.Tabs[0].Mode != "" {
|
||||
t.Fatalf("saved yolo mode = %q, want empty", got.Tabs[0].Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func userConfigPathForTest() string {
|
||||
if dir, err := os.UserConfigDir(); err == nil {
|
||||
return dir + "/reasonix/reasonix.toml"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
+2252
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testAppWithOrderedTabs(t *testing.T, active string, ids ...string) *App {
|
||||
t.Helper()
|
||||
isolateDesktopUserDirs(t)
|
||||
tabs := make(map[string]*WorkspaceTab, len(ids))
|
||||
for _, id := range ids {
|
||||
tabs[id] = &WorkspaceTab{
|
||||
ID: id,
|
||||
Scope: "global",
|
||||
TopicID: "topic_" + id,
|
||||
TopicTitle: id,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
}
|
||||
return &App{tabs: tabs, tabOrder: append([]string(nil), ids...), activeTabID: active}
|
||||
}
|
||||
|
||||
func tabIDs(tabs []TabMeta) []string {
|
||||
ids := make([]string, 0, len(tabs))
|
||||
for _, tab := range tabs {
|
||||
ids = append(ids, tab.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func assertTabIDs(t *testing.T, got []TabMeta, want ...string) {
|
||||
t.Helper()
|
||||
gotIDs := tabIDs(got)
|
||||
if len(gotIDs) != len(want) {
|
||||
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
|
||||
}
|
||||
for i := range want {
|
||||
if gotIDs[i] != want[i] {
|
||||
t.Fatalf("tab ids = %v, want %v", gotIDs, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTabsKeepsExplicitOrderWhenActiveChanges(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
||||
|
||||
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
||||
if err := app.SetActiveTab("c"); err != nil {
|
||||
t.Fatalf("SetActiveTab: %v", err)
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
||||
if got := app.activeTabID; got != "c" {
|
||||
t.Fatalf("active tab = %q, want c", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderTabsPersistsSubmittedOrder(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
||||
|
||||
if err := app.ReorderTabs([]string{"c", "a", "b"}); err != nil {
|
||||
t.Fatalf("ReorderTabs: %v", err)
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "c", "a", "b")
|
||||
if got := app.activeTabID; got != "a" {
|
||||
t.Fatalf("active tab = %q, want a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseActiveTabChoosesNeighborByOrder(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "b", "a", "b", "c")
|
||||
if err := app.CloseTab("b"); err != nil {
|
||||
t.Fatalf("CloseTab(b): %v", err)
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "a", "c")
|
||||
if got := app.activeTabID; got != "c" {
|
||||
t.Fatalf("active tab after closing middle = %q, want c", got)
|
||||
}
|
||||
|
||||
if err := app.CloseTab("c"); err != nil {
|
||||
t.Fatalf("CloseTab(c): %v", err)
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "a")
|
||||
if got := app.activeTabID; got != "a" {
|
||||
t.Fatalf("active tab after closing last = %q, want a", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderTabsRejectsInvalidOrder(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
||||
for name, order := range map[string][]string{
|
||||
"missing": {"a", "b"},
|
||||
"unknown": {"a", "b", "missing"},
|
||||
"duplicate": {"a", "b", "b"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := app.ReorderTabs(order); err == nil {
|
||||
t.Fatalf("ReorderTabs(%v) succeeded, want error", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
assertTabIDs(t, app.ListTabs(), "a", "b", "c")
|
||||
}
|
||||
|
||||
func TestNewUniqueTabIDLockedUsesFreshRandomID(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
||||
|
||||
app.mu.Lock()
|
||||
got := app.newUniqueTabIDLocked()
|
||||
app.mu.Unlock()
|
||||
if _, exists := app.tabs[got]; exists {
|
||||
t.Fatalf("newUniqueTabIDLocked returned existing id %q", got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "tab_") {
|
||||
t.Fatalf("tab id = %q, want tab_ prefix", got)
|
||||
}
|
||||
if len(got) != len("tab_")+32 {
|
||||
t.Fatalf("tab id = %q, length %d, want 36", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoredTabIDLockedReplacesEmptyAndDuplicateIDs(t *testing.T) {
|
||||
app := testAppWithOrderedTabs(t, "a", "a", "b", "c")
|
||||
|
||||
app.mu.Lock()
|
||||
kept := app.restoredTabIDLocked("d")
|
||||
duplicate := app.restoredTabIDLocked("a")
|
||||
empty := app.restoredTabIDLocked(" ")
|
||||
app.mu.Unlock()
|
||||
|
||||
if kept != "d" {
|
||||
t.Fatalf("restored unique id = %q, want d", kept)
|
||||
}
|
||||
for name, got := range map[string]string{"duplicate": duplicate, "empty": empty} {
|
||||
if _, exists := app.tabs[got]; exists {
|
||||
t.Fatalf("%s restored id %q already exists", name, got)
|
||||
}
|
||||
if !strings.HasPrefix(got, "tab_") {
|
||||
t.Fatalf("%s restored id = %q, want tab_ prefix", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,950 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func writeTopicSession(t *testing.T, dir, name, topicID, topicTitle, workspaceRoot string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(`{"role":"user","content":"hello"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
UpdatedAt: time.Now(),
|
||||
Scope: "project",
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: topicTitle,
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeLegacySession(t *testing.T, dir, name, prompt string, modTime time.Time) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(`{"role":"user","content":`+strconv.Quote(prompt)+`}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write legacy session: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatalf("chtimes legacy session: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func writeLegacyEventSession(t *testing.T, dir, name, prompt, reply string, modTime time.Time) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir legacy sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
body := `{"type":"user.message","id":1,"ts":"t","turn":0,"text":` + strconv.Quote(prompt) + `}` + "\n" +
|
||||
`{"type":"model.final","id":2,"ts":"t","turn":0,"content":` + strconv.Quote(reply) + `,"toolCalls":[],"usage":{},"costUsd":0}` + "\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("write legacy event session: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(path, modTime, modTime); err != nil {
|
||||
t.Fatalf("chtimes legacy event session: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestDeleteTopicKeepsSessionHistory(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_keep_history"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Keep history"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSession(t, dir, "keep.jsonl", topicID, "Keep history", projectRoot)
|
||||
|
||||
if err := NewApp().DeleteTopic(topicID); err != nil {
|
||||
t.Fatalf("delete topic: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); err != nil {
|
||||
t.Fatalf("delete topic should keep session history: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("topic title should be removed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameProjectUpdatesSidebarTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := NewApp().RenameProject(projectRoot, "Client API"); err != nil {
|
||||
t.Fatalf("rename project: %v", err)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("project tree len = %d, want 1", len(nodes))
|
||||
}
|
||||
if got := nodes[0].Label; got != "Client API" {
|
||||
t.Fatalf("project label = %q, want Client API", got)
|
||||
}
|
||||
|
||||
if err := NewApp().RenameProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("clear project title: %v", err)
|
||||
}
|
||||
nodes = NewApp().ListProjectTree()
|
||||
if got, want := nodes[0].Label, filepath.Base(projectRoot); got != want {
|
||||
t.Fatalf("cleared project label = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspacesUsesProjectRegistryTitles(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, "Client API"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
|
||||
workspaces := NewApp().ListWorkspaces()
|
||||
if len(workspaces) != 1 {
|
||||
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
|
||||
}
|
||||
if got := workspaces[0].Path; got != projectRoot {
|
||||
t.Fatalf("workspace path = %q, want %q", got, projectRoot)
|
||||
}
|
||||
if got := workspaces[0].Name; got != "Client API" {
|
||||
t.Fatalf("workspace name = %q, want Client API", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListWorkspacesMigratesLegacyWorkspaceList(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
legacyRoot := t.TempDir()
|
||||
rememberWorkspace(legacyRoot)
|
||||
|
||||
workspaces := NewApp().ListWorkspaces()
|
||||
if len(workspaces) != 1 {
|
||||
t.Fatalf("workspaces len = %d, want 1: %+v", len(workspaces), workspaces)
|
||||
}
|
||||
if got := workspaces[0].Path; got != legacyRoot {
|
||||
t.Fatalf("workspace path = %q, want %q", got, legacyRoot)
|
||||
}
|
||||
projects := loadProjectsFile().Projects
|
||||
if len(projects) != 1 || projects[0].Root != legacyRoot {
|
||||
t.Fatalf("legacy workspace was not migrated into projects: %+v", projects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySessionsMigrateIntoGlobalTopics(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
older := writeLegacySession(t, dir, "older.jsonl", "older imported prompt", time.Now().Add(-2*time.Hour))
|
||||
newer := writeLegacySession(t, dir, "newer.jsonl", "newer imported prompt", time.Now().Add(-time.Hour))
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if got := len(nodes[0].Children); got != 2 {
|
||||
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
|
||||
}
|
||||
if got, want := nodes[0].Children[0].TopicID, legacySessionTopicID(newer); got != want {
|
||||
t.Fatalf("newest topic first = %q, want %q", got, want)
|
||||
}
|
||||
if got, want := nodes[0].Children[1].TopicID, legacySessionTopicID(older); got != want {
|
||||
t.Fatalf("older topic second = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
meta, ok, err := agent.LoadBranchMeta(newer)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load migrated meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Scope != "global" || meta.WorkspaceRoot != "" || meta.TopicID != legacySessionTopicID(newer) {
|
||||
t.Fatalf("migrated meta = %+v", meta)
|
||||
}
|
||||
|
||||
nodes = NewApp().ListProjectTree()
|
||||
if got := len(nodes[0].Children); got != 2 {
|
||||
t.Fatalf("migration should be idempotent, global topics = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV05LegacyEventSessionsImportIntoGlobalTopic(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
|
||||
legacyDir := filepath.Join(home, ".reasonix", "sessions")
|
||||
destDir := config.SessionDir()
|
||||
writeLegacyEventSession(t, legacyDir, "v053-chat.events.jsonl", "hello from v0.53", "hi from v0.53", time.Now().Add(-time.Hour))
|
||||
|
||||
imported, err := agent.MigrateLegacySessions(legacyDir, destDir)
|
||||
if err != nil {
|
||||
t.Fatalf("migrate legacy sessions: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("imported legacy sessions = %d, want 1", imported)
|
||||
}
|
||||
migratedSession := filepath.Join(destDir, "v053-chat.jsonl")
|
||||
if _, err := os.Stat(migratedSession); err != nil {
|
||||
t.Fatalf("legacy v0.5 session was not imported to %s: %v", migratedSession, err)
|
||||
}
|
||||
|
||||
wantTopicID := legacySessionTopicID(migratedSession)
|
||||
migratedTopics := migrateLegacySessionsIntoGlobalTopics(destDir)
|
||||
if len(migratedTopics) != 1 || migratedTopics[0] != wantTopicID {
|
||||
t.Fatalf("migrated topics = %#v, want imported v0.5 topic %q", migratedTopics, wantTopicID)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != wantTopicID {
|
||||
t.Fatalf("global topics = %#v, want imported v0.5 topic %q", nodes[0].Children, wantTopicID)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(migratedSession)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load imported v0.5 meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if meta.Scope != "global" || meta.TopicID != wantTopicID {
|
||||
t.Fatalf("imported v0.5 meta = %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacySessionTopicIDsKeepNormalizedNameCollisionsDistinct(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
dotted := writeLegacySession(t, dir, "chat.1.jsonl", "dotted prompt", time.Now().Add(-2*time.Hour))
|
||||
underscored := writeLegacySession(t, dir, "chat_1.jsonl", "underscored prompt", time.Now().Add(-time.Hour))
|
||||
|
||||
dottedTopic := legacySessionTopicID(dotted)
|
||||
underscoredTopic := legacySessionTopicID(underscored)
|
||||
if dottedTopic == underscoredTopic {
|
||||
t.Fatalf("normalized legacy topic IDs collided: %q", dottedTopic)
|
||||
}
|
||||
|
||||
nodes := NewApp().ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" {
|
||||
t.Fatalf("project tree = %#v, want global folder", nodes)
|
||||
}
|
||||
if got := len(nodes[0].Children); got != 2 {
|
||||
t.Fatalf("global migrated topics = %d, want 2: %#v", got, nodes[0].Children)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, child := range nodes[0].Children {
|
||||
seen[child.TopicID] = true
|
||||
}
|
||||
if !seen[dottedTopic] || !seen[underscoredTopic] {
|
||||
t.Fatalf("global topics = %#v, want %q and %q", nodes[0].Children, dottedTopic, underscoredTopic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultGlobalTabGetsMigratedTopicID(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeLegacySession(t, dir, "legacy-tab.jsonl", "resume this legacy tab", time.Now().Add(-time.Hour))
|
||||
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_legacy",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
Ready: false,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"tab_legacy": tab},
|
||||
tabOrder: []string{"tab_legacy"},
|
||||
activeTabID: "tab_legacy",
|
||||
}
|
||||
app.buildTabController(tab)
|
||||
if tab.Ctrl != nil {
|
||||
defer tab.Ctrl.Close()
|
||||
}
|
||||
|
||||
wantTopicID := legacySessionTopicID(sessionPath)
|
||||
if tab.TopicID != wantTopicID {
|
||||
t.Fatalf("tab topicID = %q, want %q", tab.TopicID, wantTopicID)
|
||||
}
|
||||
if tab.Ctrl == nil {
|
||||
t.Fatalf("tab controller was not built")
|
||||
}
|
||||
if tab.Ctrl.SessionPath() != sessionPath {
|
||||
t.Fatalf("tab session path = %q, want %q", tab.Ctrl.SessionPath(), sessionPath)
|
||||
}
|
||||
f := loadTabsFile()
|
||||
if len(f.Tabs) != 1 || f.Tabs[0].ID != "tab_legacy" || f.Tabs[0].TopicID != wantTopicID {
|
||||
t.Fatalf("desktop tabs file = %+v, want tab id and migrated topic", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectsPersistsSidebarAndWorkspaceOrder(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
first := t.TempDir()
|
||||
second := t.TempDir()
|
||||
third := t.TempDir()
|
||||
if err := addProject(first, "First"); err != nil {
|
||||
t.Fatalf("add first project: %v", err)
|
||||
}
|
||||
if err := addProject(second, "Second"); err != nil {
|
||||
t.Fatalf("add second project: %v", err)
|
||||
}
|
||||
if err := addProject(third, "Third"); err != nil {
|
||||
t.Fatalf("add third project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
if err := app.ReorderProjects([]string{third, first, second}); err != nil {
|
||||
t.Fatalf("ReorderProjects: %v", err)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 3 {
|
||||
t.Fatalf("project tree len = %d, want 3: %+v", len(nodes), nodes)
|
||||
}
|
||||
if got := []string{nodes[0].Root, nodes[1].Root, nodes[2].Root}; got[0] != third || got[1] != first || got[2] != second {
|
||||
t.Fatalf("project tree order = %v, want %v", got, []string{third, first, second})
|
||||
}
|
||||
workspaces := app.ListWorkspaces()
|
||||
if len(workspaces) != 3 {
|
||||
t.Fatalf("workspaces len = %d, want 3: %+v", len(workspaces), workspaces)
|
||||
}
|
||||
if got := []string{workspaces[0].Path, workspaces[1].Path, workspaces[2].Path}; got[0] != third || got[1] != first || got[2] != second {
|
||||
t.Fatalf("workspace order = %v, want %v", got, []string{third, first, second})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReorderProjectsRejectsInvalidOrder(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
first := t.TempDir()
|
||||
second := t.TempDir()
|
||||
if err := addProject(first, "First"); err != nil {
|
||||
t.Fatalf("add first project: %v", err)
|
||||
}
|
||||
if err := addProject(second, "Second"); err != nil {
|
||||
t.Fatalf("add second project: %v", err)
|
||||
}
|
||||
app := NewApp()
|
||||
for name, order := range map[string][]string{
|
||||
"missing": {first},
|
||||
"unknown": {first, filepath.Join(t.TempDir(), "missing")},
|
||||
"duplicate": {first, first},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := app.ReorderProjects(order); err == nil {
|
||||
t.Fatalf("ReorderProjects(%v) succeeded, want error", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if got := []string{nodes[0].Root, nodes[1].Root}; got[0] != first || got[1] != second {
|
||||
t.Fatalf("project tree order changed after invalid reorder: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveWorkspaceUsesSharedProjectRegistryForCurrentProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
if err := addProject(projectRoot, "Current Project"); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
app := NewApp()
|
||||
tab := app.createTabEntryWithID("project", projectRoot, "topic_current", "tab_current")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
if err := app.RemoveWorkspace(projectRoot); err != nil {
|
||||
t.Fatalf("remove current project: %v", err)
|
||||
}
|
||||
if got := app.ListWorkspaces(); len(got) != 0 {
|
||||
t.Fatalf("workspaces after remove = %+v, want empty", got)
|
||||
}
|
||||
if got := app.ListProjectTree(); len(got) != 0 {
|
||||
t.Fatalf("project tree after remove = %+v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoredProjectTabUsesStoredTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_stored_title"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "你是谁"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
tabs := app.ListTabs()
|
||||
if len(tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(tabs))
|
||||
}
|
||||
if got := tabs[0].TopicTitle; got != "你是谁" {
|
||||
t.Fatalf("tab title = %q, want 你是谁", got)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].Label; got != tabs[0].TopicTitle {
|
||||
t.Fatalf("tree title = %q, want same as tab title %q", got, tabs[0].TopicTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUntitledProjectTopicUsesSameFallbackEverywhere(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_without_title"
|
||||
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{
|
||||
Root: projectRoot,
|
||||
Topics: []string{topicID},
|
||||
}}}); err != nil {
|
||||
t.Fatalf("save projects: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topicID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
tabs := app.ListTabs()
|
||||
if len(tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1", len(tabs))
|
||||
}
|
||||
if got := tabs[0].TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("tab title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want one project with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].Label; got != defaultTopicTitle {
|
||||
t.Fatalf("tree title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTopicDefaultsToAutoNewSessionTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if got := topic.Title; got != defaultTopicTitle {
|
||||
t.Fatalf("topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
||||
t.Fatalf("title source = %q, want auto", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTopicAppearsFirstInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
first, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create first topic: %v", err)
|
||||
}
|
||||
second, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create second topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 2 {
|
||||
t.Fatalf("project tree = %#v, want one project with two topics", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].TopicID; got != second.ID {
|
||||
t.Fatalf("first visible topic = %q, want newest %q", got, second.ID)
|
||||
}
|
||||
if got := nodes[0].Children[1].TopicID; got != first.ID {
|
||||
t.Fatalf("second visible topic = %q, want older %q", got, first.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateGlobalTopicAppearsFirstInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create first global topic: %v", err)
|
||||
}
|
||||
second, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create second global topic: %v", err)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 2 {
|
||||
t.Fatalf("project tree = %#v, want Global with two topics", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].TopicID; got != second.ID {
|
||||
t.Fatalf("first visible global topic = %q, want newest %q", got, second.ID)
|
||||
}
|
||||
if got := nodes[0].Children[1].TopicID; got != first.ID {
|
||||
t.Fatalf("second visible global topic = %q, want older %q", got, first.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWorkspaceRegistersDefaultTopicInProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
if got, err := app.SwitchWorkspace(projectRoot); err != nil {
|
||||
t.Fatalf("SwitchWorkspace: %v", err)
|
||||
} else if got != projectRoot {
|
||||
t.Fatalf("SwitchWorkspace root = %q, want %q", got, projectRoot)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("project tree len = %d, want 1: %+v", len(nodes), nodes)
|
||||
}
|
||||
if got := nodes[0].Root; got != projectRoot {
|
||||
t.Fatalf("project root = %q, want %q", got, projectRoot)
|
||||
}
|
||||
if len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project children len = %d, want 1: %+v", len(nodes[0].Children), nodes[0].Children)
|
||||
}
|
||||
child := nodes[0].Children[0]
|
||||
if got := child.Label; got != defaultTopicTitle {
|
||||
t.Fatalf("default topic label = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if strings.TrimSpace(child.TopicID) == "" {
|
||||
t.Fatalf("default topic ID should be persisted in the project tree: %+v", child)
|
||||
}
|
||||
tabs := app.ListTabs()
|
||||
if len(tabs) != 1 || tabs[0].TopicID != child.TopicID {
|
||||
t.Fatalf("opened tab should use the persisted topic, tabs=%+v child=%+v", tabs, child)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTopicLocksTitleManual(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
|
||||
t.Fatalf("rename topic: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
|
||||
t.Fatalf("stored title = %q, want 手动标题", got)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
||||
t.Fatalf("title source = %q, want manual", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameTopicUpdatesOpenTabMeta(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "旧标题")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
tab, err := app.OpenProjectTab(projectRoot, topic.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("open project tab: %v", err)
|
||||
}
|
||||
if tab.TopicTitle != "旧标题" {
|
||||
t.Fatalf("opened tab title = %q, want 旧标题", tab.TopicTitle)
|
||||
}
|
||||
|
||||
if err := app.RenameTopic(topic.ID, "新标题"); err != nil {
|
||||
t.Fatalf("rename topic: %v", err)
|
||||
}
|
||||
tabs := app.ListTabs()
|
||||
if len(tabs) != 1 {
|
||||
t.Fatalf("tabs len = %d, want 1: %+v", len(tabs), tabs)
|
||||
}
|
||||
if got := tabs[0].TopicTitle; got != "新标题" {
|
||||
t.Fatalf("open tab title = %q, want 新标题", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleTopicFromFirstUserMessage(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topic, err := NewApp().CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
|
||||
title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath)
|
||||
if !updated {
|
||||
t.Fatal("auto title should update")
|
||||
}
|
||||
if title != "讲讲这个代码库的架构" {
|
||||
t.Fatalf("generated title = %q", title)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != title {
|
||||
t.Fatalf("stored title = %q, want %q", got, title)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
||||
t.Fatalf("title source = %q, want auto", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoTitleDoesNotOverrideManualTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := app.RenameTopic(topic.ID, "手动标题"); err != nil {
|
||||
t.Fatalf("rename topic: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
if err := os.WriteFile(sessionPath, []byte(`{"role":"user","content":"讲讲这个代码库的架构"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write session: %v", err)
|
||||
}
|
||||
|
||||
if title, updated := autoTitleTopicFromSession(projectRoot, topic.ID, sessionPath); updated || title != "" {
|
||||
t.Fatalf("manual title should not auto-update, title=%q updated=%v", title, updated)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "手动标题" {
|
||||
t.Fatalf("stored title = %q, want 手动标题", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicMovesRelatedSessionsToTrash(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_trash_history"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Trash history"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeTopicSession(t, dir, "trash-me.jsonl", topicID, "Trash history", projectRoot)
|
||||
|
||||
if err := NewApp().TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("trash topic: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("topic session should be removed from active history, stat err = %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "trash-me.jsonl", "trash-me.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("topic session should be moved to trash: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("topic title should be removed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreGlobalTopicSessionReindexesProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeLegacySession(t, dir, "restore-global.jsonl", "restore global history", time.Now().Add(-time.Hour))
|
||||
topicID := legacySessionTopicID(sessionPath)
|
||||
app := NewApp()
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
||||
t.Fatalf("legacy session should start in Global, got %#v", nodes)
|
||||
}
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("trash global topic: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "restore-global.jsonl", "restore-global.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("global session should be in trash: %v", err)
|
||||
}
|
||||
if got := app.ListProjectTree(); len(got) != 0 {
|
||||
t.Fatalf("trashed global topic should leave project tree, got %#v", got)
|
||||
}
|
||||
|
||||
if err := app.RestoreSession(trashPath); err != nil {
|
||||
t.Fatalf("restore global session: %v", err)
|
||||
}
|
||||
if got := app.ListTrashedSessions(); len(got) != 0 {
|
||||
t.Fatalf("trash should be empty after restore, got %#v", got)
|
||||
}
|
||||
nodes = app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
||||
t.Fatalf("restored global session should reappear in Global, got %#v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreProjectTopicSessionReindexesProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_restore_project"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Project restore"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
writeTopicSession(t, dir, "restore-project.jsonl", topicID, "Project restore", projectRoot)
|
||||
app := NewApp()
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("trash project topic: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "restore-project.jsonl", "restore-project.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("project session should be in trash: %v", err)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("topic title should be removed while trashed, got %q", got)
|
||||
}
|
||||
|
||||
if err := app.RestoreSession(trashPath); err != nil {
|
||||
t.Fatalf("restore project session: %v", err)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "project" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
||||
t.Fatalf("restored project session should reappear in project tree, got %#v", nodes)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "Project restore" {
|
||||
t.Fatalf("restored topic title = %q, want Project restore", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreSessionWithoutTopicMetadataFallsBackToGlobal(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := writeLegacySession(t, dir, "restore-orphan.jsonl", "restore orphan history", time.Now().Add(-time.Hour))
|
||||
topicID := legacySessionTopicID(sessionPath)
|
||||
app := NewApp()
|
||||
if err := app.DeleteSession(sessionPath); err != nil {
|
||||
t.Fatalf("delete orphan session: %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "restore-orphan.jsonl", "restore-orphan.jsonl")
|
||||
|
||||
if err := app.RestoreSession(trashPath); err != nil {
|
||||
t.Fatalf("restore orphan session: %v", err)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 || nodes[0].Children[0].TopicID != topicID {
|
||||
t.Fatalf("restored orphan session should fall back to Global, got %#v", nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_open_trash"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Open trash"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
sessionPath := filepath.Join(dir, "open-trash.jsonl")
|
||||
if err := agent.SaveBranchMeta(sessionPath, agent.BranchMeta{
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
UpdatedAt: time.Now(),
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Open trash",
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
openTab := &WorkspaceTab{
|
||||
ID: "tab_open",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Open trash",
|
||||
Ctrl: controllerWithContent(t, sessionPath),
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
otherTab := &WorkspaceTab{
|
||||
ID: "tab_other",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: "topic_keep",
|
||||
TopicTitle: "Keep",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"tab_open": openTab, "tab_other": otherTab},
|
||||
tabOrder: []string{"tab_open", "tab_other"},
|
||||
activeTabID: "tab_open",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("trash topic: %v", err)
|
||||
}
|
||||
if _, ok := app.tabs["tab_open"]; ok {
|
||||
t.Fatalf("open tab for trashed topic should be removed")
|
||||
}
|
||||
if got := app.activeTabID; got != "tab_other" {
|
||||
t.Fatalf("active tab = %q, want tab_other", got)
|
||||
}
|
||||
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("open topic session should be removed from active history, stat err = %v", err)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "open-trash.jsonl", "open-trash.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("open topic session should be moved to trash: %v", err)
|
||||
}
|
||||
trashed := app.ListTrashedSessions()
|
||||
if len(trashed) != 1 || trashed[0].Path != trashPath {
|
||||
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topicID); got != "" {
|
||||
t.Fatalf("topic title should be removed, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMigrationSkipsProjectScopedSessions(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := writeLegacySession(t, dir, "scoped.jsonl", "hello", time.Now())
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.Scope = "project"
|
||||
meta.WorkspaceRoot = filepath.Join(t.TempDir(), "proj")
|
||||
meta.TopicID = ""
|
||||
if err := agent.SaveBranchMeta(path, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
migrateLegacySessionsIntoGlobalTopics(dir)
|
||||
|
||||
got, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Scope != "project" || got.WorkspaceRoot != meta.WorkspaceRoot {
|
||||
t.Fatalf("project-scoped legacy session must not be forced into Global: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyMigrationConcurrentRunsHaveNoLostUpdates(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const n = 8
|
||||
want := make(map[string]bool, n)
|
||||
for i := 0; i < n; i++ {
|
||||
p := writeLegacySession(t, dir, fmt.Sprintf("legacy-%d.jsonl", i), "hi", time.Now())
|
||||
want[legacySessionTopicID(p)] = true
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
migrateLegacySessionsIntoGlobalTopics(dir)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
gotSet := map[string]bool{}
|
||||
for _, id := range loadProjectsFile().GlobalTopics {
|
||||
gotSet[id] = true
|
||||
}
|
||||
for id := range want {
|
||||
if !gotSet[id] {
|
||||
t.Fatalf("concurrent migration lost topic %q; GlobalTopics=%v", id, loadProjectsFile().GlobalTopics)
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"fyne.io/systray"
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
type desktopTray struct {
|
||||
end func()
|
||||
openItem *systray.MenuItem
|
||||
quitItem *systray.MenuItem
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (a *App) startTray() {
|
||||
if !traySupported() {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.tray != nil {
|
||||
a.mu.Unlock()
|
||||
return
|
||||
}
|
||||
t := &desktopTray{}
|
||||
a.tray = t
|
||||
a.mu.Unlock()
|
||||
|
||||
t.end = startDesktopTray(func() {
|
||||
systray.SetIcon(trayIconBytes)
|
||||
systray.SetTitle("Reasonix")
|
||||
systray.SetTooltip("Reasonix")
|
||||
systray.SetOnTapped(func() { a.showFromTray() })
|
||||
|
||||
labels := trayMenuLabels(a.trayLocale())
|
||||
t.openItem = systray.AddMenuItem(labels.openTitle, labels.openTooltip)
|
||||
t.quitItem = systray.AddMenuItem(labels.quitTitle, labels.quitTooltip)
|
||||
|
||||
a.mu.Lock()
|
||||
a.trayReady = true
|
||||
a.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
for range t.openItem.ClickedCh {
|
||||
a.showFromTray()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
for range t.quitItem.ClickedCh {
|
||||
a.quitFromTray()
|
||||
}
|
||||
}()
|
||||
}, func() {
|
||||
a.mu.Lock()
|
||||
a.trayReady = false
|
||||
a.mu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopTray() {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.end == nil {
|
||||
return
|
||||
}
|
||||
t.once.Do(t.end)
|
||||
}
|
||||
|
||||
func (a *App) updateTrayLocale(locale string) {
|
||||
a.mu.RLock()
|
||||
t := a.tray
|
||||
a.mu.RUnlock()
|
||||
if t == nil || t.openItem == nil || t.quitItem == nil {
|
||||
return
|
||||
}
|
||||
labels := trayMenuLabels(locale)
|
||||
t.openItem.SetTitle(labels.openTitle)
|
||||
t.openItem.SetTooltip(labels.openTooltip)
|
||||
t.quitItem.SetTitle(labels.quitTitle)
|
||||
t.quitItem.SetTooltip(labels.quitTooltip)
|
||||
}
|
||||
|
||||
func (a *App) trayLocale() string {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.DesktopLanguage()
|
||||
}
|
||||
|
||||
func (a *App) showFromTray() {
|
||||
ctx := a.ctx
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
wruntime.Show(ctx)
|
||||
wruntime.WindowShow(ctx)
|
||||
wruntime.WindowUnminimise(ctx)
|
||||
}
|
||||
|
||||
func (a *App) quitFromTray() {
|
||||
a.quitApp()
|
||||
}
|
||||
|
||||
type trayLabels struct {
|
||||
openTitle string
|
||||
openTooltip string
|
||||
quitTitle string
|
||||
quitTooltip string
|
||||
}
|
||||
|
||||
func trayMenuLabels(locale string) trayLabels {
|
||||
if locale == "zh" {
|
||||
return trayLabels{
|
||||
openTitle: "打开",
|
||||
openTooltip: "打开 Reasonix 窗口",
|
||||
quitTitle: "退出",
|
||||
quitTooltip: "退出 Reasonix",
|
||||
}
|
||||
}
|
||||
return trayLabels{
|
||||
openTitle: "Open",
|
||||
openTooltip: "Open the Reasonix window",
|
||||
quitTitle: "Quit",
|
||||
quitTooltip: "Quit Reasonix",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed build/appicon.png
|
||||
var trayIconBytes []byte
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// Windows systray loads icons through LoadImage(IMAGE_ICON), so it needs ICO
|
||||
// bytes rather than the PNG app icon used by macOS/Linux.
|
||||
//
|
||||
//go:embed build/windows/icon.ico
|
||||
var trayIconBytes []byte
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestWindowsTrayIconUsesICOBytes(t *testing.T) {
|
||||
if len(trayIconBytes) < 4 {
|
||||
t.Fatalf("tray icon is too small: %d bytes", len(trayIconBytes))
|
||||
}
|
||||
if trayIconBytes[0] != 0x00 || trayIconBytes[1] != 0x00 || trayIconBytes[2] != 0x01 || trayIconBytes[3] != 0x00 {
|
||||
t.Fatalf("Windows tray icon must be ICO bytes, got header % x", trayIconBytes[:4])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
func startDesktopTray(onReady, onExit func()) func() {
|
||||
start, end := systray.RunWithExternalLoop(onReady, onExit)
|
||||
start()
|
||||
return end
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
func startDesktopTray(onReady, onExit func()) func() {
|
||||
go systray.Run(onReady, onExit)
|
||||
return systray.Quit
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
// systray creates the NSStatusItem (an NSWindow) on the calling goroutine, but
|
||||
// Cocoa demands the main thread — which Wails owns — so the tray crashes the app
|
||||
// at launch (#3223). macOS backgrounds via runtime.Hide + the Dock instead.
|
||||
func traySupported() bool { return false }
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user