Compare commits

..

74 Commits

Author SHA1 Message Date
Shantur Rathore
482313f662 fix(ui): render image attachment preview in portal 2026-02-28 00:56:44 +00:00
Shantur Rathore
9a4d378238 perf(ui): avoid full rescan of task child tools 2026-02-27 21:09:46 +00:00
Shantur Rathore
5d5fbfb5f2 perf(ui): lazy-mount tool call details 2026-02-27 13:28:43 +00:00
Shantur Rathore
d147ad49ff chore(ui): remove tool header button borders 2026-02-27 00:13:05 +00:00
Shantur Rathore
9b435e3621 chore(config): bump @opencode-ai/plugin 2026-02-26 15:34:14 +00:00
Shantur Rathore
ab9e188b02 feat(ui): add multi-select message deletion 2026-02-26 15:25:47 +00:00
Shantur Rathore
2991de528a feat(ui): add delete-up-to action and range hover overlay 2026-02-26 13:46:48 +00:00
Shantur Rathore
f1bd681618 chore(ui): remove delete-part actions and use trash for delete 2026-02-26 10:25:38 +00:00
Shantur Rathore
b91dbb1a60 fix(ui): sync delete-hover overlays across preview and stream 2026-02-26 10:10:46 +00:00
Shantur Rathore
688b127c6d fix(ui): highlight all tool segments on message delete hover 2026-02-26 09:34:34 +00:00
Shantur Rathore
0f9c99e3bd feat(ui): mirror delete hover overlay in timeline 2026-02-25 23:32:32 +00:00
Shantur Rathore
1122070b9c feat(ui): highlight delete targets on hover 2026-02-25 23:08:53 +00:00
Shantur Rathore
57b81f00f8 chore(ui): reorder user message actions 2026-02-25 22:54:49 +00:00
Shantur Rathore
362105fe78 feat(ui): add delete message action to stream 2026-02-25 22:49:14 +00:00
Shantur Rathore
5834d2df1b fix(ui): use v2 message info and show model variant 2026-02-25 22:29:27 +00:00
Shantur Rathore
ef4c8ef425 fix(ci): ad-hoc sign Electron macOS apps 2026-02-24 22:22:46 +00:00
Shantur Rathore
5f755a7e1c fix(ci): retry workspace version bump on macos 2026-02-24 09:08:32 +00:00
Shantur Rathore
8607fab5b5 fix(ci): skip macOS codesign verify without identity 2026-02-24 08:53:14 +00:00
Shantur Rathore
0368fe8248 fix(ci): avoid bash globstar on macOS 2026-02-24 07:29:26 +00:00
Shantur Rathore
b970281fa7 chore(electron): use cross-env for dev log level scripts
Make dev:info/dev:debug/dev:trace work on Windows by setting CLI_LOG_LEVEL via cross-env.
2026-02-24 00:19:39 +00:00
Shantur Rathore
8e5a7fc213 fix(electron): make dev CLI log level configurable
Use CLI_LOG_LEVEL when launching the server in desktop dev and add dev:info/dev:debug/dev:trace scripts with dev defaulting to info.
2026-02-24 00:09:49 +00:00
Shantur Rathore
15f362e8b5 Bump v0.11.5 2026-02-23 23:55:52 +00:00
Shantur Rathore
7bbd0a1787 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-23 23:55:32 +00:00
Shantur Rathore
f8aae56728 Merge pull request #190 from VooDisss/issue-187
fix(ui): prevent timeline auto-scroll when removing badges (#189)
2026-02-23 21:50:56 +00:00
Shantur Rathore
027d7fc97d fix(ui): load shiki languages from marked tokens 2026-02-23 18:39:21 +00:00
Shantur Rathore
e90aef4b3c fix(ui): stack instance header under 1024px 2026-02-23 18:36:24 +00:00
Shantur Rathore
e4e89008b2 Merge pull request #199 from NeuralNomadsAI/codenomad/issue-198
CI: rezip Electron macOS artifacts with ditto + validate codesign
2026-02-23 08:58:56 +00:00
Shantur Rathore
90baefbb7e fix(ci): rezip Electron macOS zips with ditto
Add a codesign verify step on extracted artifacts to catch signature/resource mismatches before upload.
2026-02-23 08:54:57 +00:00
Shantur Rathore
1c138f4489 Merge pull request #197 from VooDisss/issue-195
fix: Use legacy diff algorithm for better large file performance
2026-02-23 08:27:11 +00:00
VooDisss
d36e568ed0 fix: Use legacy diff algorithm for better large file performance
- Set diffAlgorithm to 'legacy' for Monaco DiffEditor
- Add maxComputationTime of 10s to avoid UI freeze on huge files

This addresses the issue where sessions with large JSON files (50k-100k+ lines)
would cause the UI to freeze. The 'legacy' algorithm is faster than 'advanced'
for large files, similar to VSCode's workaround for the same issue.

See: https://github.com/microsoft/vscode/issues/184037
2026-02-23 02:30:44 +02:00
Shantur Rathore
d6462ef524 Min version 0.11.4 2026-02-22 17:32:28 +00:00
Shantur Rathore
a06884ebce Bump to v0.11.4 2026-02-22 16:53:51 +00:00
Shantur Rathore
62bd88f6a4 chore(plugin): Upgrade dependency version 2026-02-22 16:48:49 +00:00
Shantur Rathore
6479561779 fix(ui): auto-expand session thread when child starts working 2026-02-22 16:47:04 +00:00
Shantur Rathore
635237c258 fix(ui): render task prompt consistently while running 2026-02-22 08:58:39 +00:00
Shantur Rathore
33f0aa5714 ci: run dev prerelease nightly
Replace dev push builds with nightly schedule that only runs when dev head advances; still runs on manual dispatch. Plumb a ref input through reusable workflows so scheduled runs build the dev commit.
2026-02-20 13:58:32 +00:00
Shantur Rathore
7ca6285d58 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-20 13:49:03 +00:00
Shantur Rathore
14c60fef6c Merge pull request #192 from VooDisss/issue-144
[QOL] Add informational tooltips to Status Panel sections
2026-02-20 13:47:11 +00:00
Shantur Rathore
336de6a19e fix(i18n): polish Status panel tooltip translations 2026-02-20 13:46:43 +00:00
Shantur Rathore
377c8e2249 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-20 13:31:52 +00:00
VooDisss
697dea21f8 Add informational tooltips to Status Panel sections 2026-02-20 14:09:54 +02:00
Shantur Rathore
34d3f803d5 Merge pull request #191 from kvokka/improve-docs
Clarify CLI_WORKSPACE_ROOT usage for worktrees
2026-02-20 11:15:01 +00:00
kvokka
f824a063a5 docs: clarify CLI_WORKSPACE_ROOT usage for worktrees\n\nFixes #184 2026-02-20 14:52:05 +04:00
VooDisss
96fe1b86dd fix(ui): prevent timeline auto-scroll when removing badges 2026-02-20 12:33:52 +02:00
Shantur Rathore
5fabf286e8 ui: restyle command palette button 2026-02-20 00:32:44 +00:00
Shantur Rathore
e8947d61b1 ui: emphasize command palette button 2026-02-20 00:32:39 +00:00
Shantur Rathore
1ccd14eae8 ui: use Check icon for completed status 2026-02-20 00:32:27 +00:00
Shantur Rathore
b162764ccb ui: use lucide status icons for tool calls 2026-02-20 00:32:15 +00:00
Shantur Rathore
2124e540aa Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-02-19 23:54:31 +00:00
Shantur Rathore
b5790998b7 ui: use emoji status icons for tool calls 2026-02-19 23:51:25 +00:00
codenomadbot[bot]
9800afb785 feat(ui): toggle tool call input YAML (#182)
* feat(ui): toggle tool call input yaml

* ui: rename tool input toggle and add IO headers

* ui: add input/output accordions in tool calls

* ui: refine tool IO accordion styling

* ui: remove extra padding around IO sections

* ui: remove semibold from IO headers

* feat(ui): add tool input visibility preference

* fix(ui): scope tool input toggle to current tool call

* ui: left-align tool IO header text

* fix(ui): let palette tool input visibility override per-call

* ui: default tool input visibility to collapsed

* fix(ui): expand read tool calls on error

---------

Co-authored-by: Shantur Rathore <i@shantur.com>
2026-02-19 22:08:41 +00:00
Shantur Rathore
3b73d9d5b9 fix(ui): show workspace launch errors in dialog 2026-02-19 15:40:58 +00:00
Shantur Rathore
f7ac30afe3 revert(ui): restore compact alert dialog 2026-02-19 15:40:55 +00:00
Shantur Rathore
ce370d5100 fix(server): read OpenCode version from /global/health 2026-02-19 14:21:13 +00:00
Shantur Rathore
c639e535b5 fix(ui): add blank line after inserted quotes 2026-02-19 10:40:51 +00:00
Shantur Rathore
e84adebe61 fix(server): detect OpenCode version via spawn spec 2026-02-19 07:24:14 +00:00
Shantur Rathore
d45a1ff078 Bump to v0.11.3 2026-02-18 19:59:54 +00:00
Shantur Rathore
b4121696bb fix(ui): track worktree context for question replies
Store the originating worktree slug when questions are enqueued and use
the stored worktree client when replying/rejecting from the global
permission center. This ensures question responses are sent through the
correct worktree, matching the behavior already implemented for
permissions.
2026-02-18 19:56:42 +00:00
Shantur Rathore
f75c942162 fix(ui): exclude hidden agents from pickers 2026-02-18 16:00:58 +00:00
Shantur Rathore
127a1f628d feat(server,ui): allow OpenCode directory override via proxy path 2026-02-18 09:43:30 +00:00
Shantur Rathore
859312ba3b feat(ui): add dispose instance and rehydrate
Adds a dispose instance action to the instance info view, POSTing to /instance/dispose and rehydrating per-instance stores; also handles server.instance.disposed events and adds danger button styling.
2026-02-18 01:07:52 +00:00
Shantur Rathore
4eaa711f01 fix(ui): make alert dialog scrollable for long errors 2026-02-18 00:27:26 +00:00
Shantur Rathore
c8ff858565 fix(ui): render user message text as markdown
User text parts now use the same Markdown renderer + cache path as assistant messages, while keeping role-specific heading and accent colors.
2026-02-17 22:44:30 +00:00
Shantur Rathore
6de6ef5a4a Bump to v0.11.2 2026-02-17 18:47:21 +00:00
Shantur Rathore
4dee154490 docs: add star history chart 2026-02-17 18:43:02 +00:00
Shantur Rathore
ef388adc4f fix(server): avoid back to login after auth
Replace /login history entry on success and redirect authenticated /login to /, with no-store headers to prevent caching.
2026-02-17 18:27:41 +00:00
Shantur Rathore
e8cfad1266 fix(ui): anchor fullscreen exit button to viewport
Render the mobile fullscreen exit button at the App root so fixed positioning stays pinned to the top-right regardless of instance header visibility.
2026-02-17 18:13:44 +00:00
Shantur Rathore
3f82dd21fe fix(ui): reduce prompt expanded height on mobile
Use the existing instance shell layout mode to cap expanded prompt rows to 10 on phone/tablet while keeping 15 on desktop.
2026-02-17 18:04:37 +00:00
Shantur Rathore
dc13d9a7d0 fix(ui): avoid mobile prompt focus on switch
Stops auto-focusing the prompt on phone session switches and scopes type-to-focus to the active visible prompt, disabling it on coarse pointers.
2026-02-17 18:00:48 +00:00
Shantur Rathore
29557fba6d feat(ui): add mobile fullscreen mode
Adds an in-memory mobile fullscreen toggle that hides chrome and uses the Fullscreen API when available.
2026-02-17 17:30:03 +00:00
Shantur Rathore
dea5079713 feat(ui): add diff toolbar toggles and word wrap
Replace split/unified and context controls with icon toggles, add a word-wrap toggle (default on), and move the toolbar into the tab header to free vertical space.
2026-02-17 13:47:07 +00:00
Shantur Rathore
ddc58a2c3c feat(ui): add context meter indicator
Replace duplicated Used/Avail pills with a shared ContextMeter component and add a small filled context usage indicator for quick scanning.
2026-02-17 12:26:03 +00:00
Shantur Rathore
eafd4d83af fix(ui): use model input limit for avail tokens
Upgrade @opencode-ai/sdk to 1.2.6 and prefer v2 model limit.input when present for the session AVAIL chip; otherwise keep the existing context-window-based estimate.
2026-02-17 11:13:17 +00:00
Shantur Rathore
1a0734c6b1 fix(ui): persist listening mode before restart 2026-02-16 21:39:46 +00:00
132 changed files with 4231 additions and 1051 deletions

View File

@@ -3,6 +3,11 @@ name: Build and Upload Binaries
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version: version:
description: "Version to apply to workspace packages (release builds)" description: "Version to apply to workspace packages (release builds)"
required: false required: false
@@ -45,6 +50,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -54,7 +61,21 @@ jobs:
- name: Set workspace versions - name: Set workspace versions
if: ${{ inputs.set_versions && inputs.version != '' }} if: ${{ inputs.set_versions && inputs.version != '' }}
run: npm version ${VERSION} --workspaces --include-workspace-root --no-git-tag-version --allow-same-version shell: bash
env:
NPM_CONFIG_FETCH_RETRIES: 5
NPM_CONFIG_FETCH_RETRY_MINTIMEOUT: 20000
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: 120000
run: |
set -euo pipefail
for attempt in 1 2 3; do
if npm version "${VERSION}" --workspaces --include-workspace-root --no-git-tag-version --allow-same-version; then
exit 0
fi
echo "npm version failed (attempt $attempt/3); retrying..." >&2
sleep $((attempt * 10))
done
exit 1
- name: Install dependencies - name: Install dependencies
run: npm ci --workspaces --include=optional run: npm ci --workspaces --include=optional
@@ -65,6 +86,112 @@ jobs:
- name: Build macOS binaries (Electron) - name: Build macOS binaries (Electron)
run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app run: npm run build:mac --workspace @neuralnomads/codenomad-electron-app
- name: Ad-hoc sign Electron macOS app bundles (seal resources)
shell: bash
run: |
set -euo pipefail
release_root="packages/electron-app/release"
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
# GitHub macOS runners typically have no signing identity. Without any signature,
# the shipped .app can fail Gatekeeper with:
# code has no resources but signature indicates they must be present
# Ad-hoc signing seals bundle resources and makes the signature internally consistent.
if security find-identity -p codesigning -v | grep -q "0 valid identities found"; then
echo "No valid macOS codesigning identity found; applying ad-hoc signature"
for app in "${apps[@]}"; do
echo "codesign (adhoc): $app"
codesign --force --deep --sign - "$app"
codesign --verify --deep --strict --verbose=2 "$app"
done
else
echo "macOS codesigning identity present; skipping ad-hoc signing"
fi
- name: Repackage Electron macOS zips (ditto)
shell: bash
run: |
set -euo pipefail
# Prefer the workflow-provided version; fall back to package.json.
VERSION_TO_USE="${VERSION:-}"
if [ -z "$VERSION_TO_USE" ]; then
VERSION_TO_USE=$(node -p "require('./packages/electron-app/package.json').version")
fi
release_root="packages/electron-app/release"
# macOS GitHub runners ship /bin/bash 3.2 which doesn't support `shopt -s globstar`.
# Use find to locate built app bundles instead of ** globs.
apps=()
while IFS= read -r -d '' app; do
apps+=("$app")
done < <(find "$release_root" -type d -name 'CodeNomad.app' -print0)
if [ "${#apps[@]}" -eq 0 ]; then
echo "No CodeNomad.app found under $release_root" >&2
exit 1
fi
for app in "${apps[@]}"; do
bundle_dir=$(basename "$(dirname "$app")")
arch="x64"
if [[ "$bundle_dir" == *"arm64"* ]]; then
arch="arm64"
fi
out_zip="$release_root/CodeNomad-${VERSION_TO_USE}-mac-${arch}.zip"
rm -f "$out_zip"
echo "ditto -ck: $app -> $out_zip"
ditto -ck --sequesterRsrc --keepParent "$app" "$out_zip"
done
- name: Validate Electron macOS codesign (unzipped)
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
zips=(packages/electron-app/release/CodeNomad-*-mac-*.zip)
if [ "${#zips[@]}" -eq 0 ]; then
echo "No Electron macOS zip artifacts found to validate" >&2
exit 1
fi
for zip in "${zips[@]}"; do
echo "Validating codesign for: $zip"
extract_dir="$tmp_dir/$(basename "$zip" .zip)"
mkdir -p "$extract_dir"
# Use ditto for extraction as well to preserve bundle metadata.
ditto -x -k "$zip" "$extract_dir"
app_path=""
for candidate in "$extract_dir"/*.app "$extract_dir"/*/*.app; do
if [ -d "$candidate" ]; then
app_path="$candidate"
break
fi
done
if [ -z "$app_path" ]; then
echo "No .app found after extracting $zip" >&2
exit 1
fi
codesign --verify --deep --strict --verbose=2 "$app_path"
done
- name: Upload release assets - name: Upload release assets
if: ${{ inputs.upload && inputs.tag != '' }} if: ${{ inputs.upload && inputs.tag != '' }}
run: | run: |
@@ -85,6 +212,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -124,6 +253,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -164,6 +295,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -237,6 +370,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -310,6 +445,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -388,6 +525,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -490,6 +629,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -587,6 +728,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,12 +1,13 @@
name: Develop Pre-Release name: Develop Pre-Release
on: on:
push: schedule:
branches: # Nightly build of dev (only if dev has new commits)
- dev - cron: "0 1 * * *"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
actions: read
id-token: write id-token: write
contents: write contents: write
@@ -15,25 +16,63 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
prepare: gate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
version_suffix: ${{ steps.vars.outputs.version_suffix }} run: ${{ steps.gate.outputs.run }}
dev_sha: ${{ steps.gate.outputs.dev_sha }}
version_suffix: ${{ steps.gate.outputs.version_suffix }}
steps: steps:
- name: Compute version suffix - name: Decide whether to run
id: vars id: gate
shell: bash shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
SHA8="${GITHUB_SHA::8}"
api() {
curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$1"
}
DEV_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" | jq -r '.object.sha')
if [ -z "$DEV_SHA" ] || [ "$DEV_SHA" = "null" ]; then
echo "Failed to resolve dev head SHA" >&2
exit 1
fi
DATE=$(date -u +%Y%m%d) DATE=$(date -u +%Y%m%d)
echo "version_suffix=-dev-${DATE}-${SHA8}" >> "$GITHUB_OUTPUT" SHA8="${DEV_SHA::8}"
VERSION_SUFFIX="-dev-${DATE}-${SHA8}"
SHOULD_RUN="false"
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
SHOULD_RUN="true"
else
# Nightly: only run if dev has advanced since last successful dev-release build.
LAST_SHA=$(api "https://api.github.com/repos/${GITHUB_REPOSITORY}/actions/workflows/dev-release.yml/runs?branch=dev&status=success&per_page=1" | jq -r '.workflow_runs[0].head_sha // empty')
if [ -z "${LAST_SHA}" ]; then
SHOULD_RUN="true"
elif [ "${LAST_SHA}" != "${DEV_SHA}" ]; then
SHOULD_RUN="true"
fi
fi
echo "run=${SHOULD_RUN}" >> "$GITHUB_OUTPUT"
echo "dev_sha=${DEV_SHA}" >> "$GITHUB_OUTPUT"
echo "version_suffix=${VERSION_SUFFIX}" >> "$GITHUB_OUTPUT"
prerelease: prerelease:
needs: prepare needs: gate
if: ${{ needs.gate.outputs.run == 'true' }}
uses: ./.github/workflows/reusable-release.yml uses: ./.github/workflows/reusable-release.yml
with: with:
version_suffix: ${{ needs.prepare.outputs.version_suffix }} ref: ${{ needs.gate.outputs.dev_sha }}
version_suffix: ${{ needs.gate.outputs.version_suffix }}
npm_package_name: "@neuralnomads/codenomad-dev" npm_package_name: "@neuralnomads/codenomad-dev"
dist_tag: latest dist_tag: latest
prerelease: true prerelease: true

View File

@@ -19,6 +19,10 @@ on:
type: string type: string
workflow_call: workflow_call:
inputs: inputs:
ref:
required: false
default: ""
type: string
version: version:
required: true required: true
type: string type: string
@@ -46,6 +50,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1,7 +1,13 @@
name: Release UI name: Release UI
on: on:
workflow_call: {} workflow_call:
inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
workflow_dispatch: {} workflow_dispatch: {}
permissions: permissions:
@@ -18,6 +24,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -3,6 +3,11 @@ name: Reusable Release
on: on:
workflow_call: workflow_call:
inputs: inputs:
ref:
description: "Git ref (branch, tag, or SHA) to build from"
required: false
default: ""
type: string
version_suffix: version_suffix:
description: "Suffix appended to package.json version" description: "Suffix appended to package.json version"
required: false required: false
@@ -46,6 +51,8 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ inputs.ref || github.ref }}
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
@@ -84,6 +91,7 @@ jobs:
needs: prepare-release needs: prepare-release
uses: ./.github/workflows/build-and-upload.yml uses: ./.github/workflows/build-and-upload.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
tag: ${{ needs.prepare-release.outputs.tag }} tag: ${{ needs.prepare-release.outputs.tag }}
release_name: ${{ needs.prepare-release.outputs.release_name }} release_name: ${{ needs.prepare-release.outputs.release_name }}
@@ -95,6 +103,8 @@ jobs:
permissions: permissions:
contents: read contents: read
uses: ./.github/workflows/release-ui.yml uses: ./.github/workflows/release-ui.yml
with:
ref: ${{ inputs.ref || github.ref }}
secrets: inherit secrets: inherit
publish-server: publish-server:
@@ -103,6 +113,7 @@ jobs:
- build-and-upload - build-and-upload
uses: ./.github/workflows/manual-npm-publish.yml uses: ./.github/workflows/manual-npm-publish.yml
with: with:
ref: ${{ inputs.ref || github.ref }}
version: ${{ needs.prepare-release.outputs.version }} version: ${{ needs.prepare-release.outputs.version }}
dist_tag: ${{ inputs.dist_tag }} dist_tag: ${{ inputs.dist_tag }}
package_name: ${{ inputs.npm_package_name }} package_name: ${{ inputs.npm_package_name }}

View File

@@ -123,3 +123,6 @@ To build the Desktop App from source:
1. Clone the repo. 1. Clone the repo.
2. Run `npm install` (requires pnpm or npm 7+ for workspaces). 2. Run `npm install` (requires pnpm or npm 7+ for workspaces).
3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`. 3. Run `npm run build --workspace @neuralnomads/codenomad-electron-app`.
[![Star History Chart](https://api.star-history.com/svg?repos=NeuralNomadsAI/CodeNomad&type=Date)](https://star-history.com/#NeuralNomadsAI/CodeNomad&Date)

23
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.1", "version": "0.11.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.1", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
@@ -2809,9 +2809,9 @@
} }
}, },
"node_modules/@opencode-ai/sdk": { "node_modules/@opencode-ai/sdk": {
"version": "1.1.11", "version": "1.2.6",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.11.tgz", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.6.tgz",
"integrity": "sha512-vqdNDz8Q+4bygmDdQem6oxhU31ci4JVdoND4ZJNeCs9x6OIU6MM3ybgemGpzNkgtJDlfb4xCdrPaZZ6Sr3V1IQ==", "integrity": "sha512-dWMF8Aku4h7fh8sw5tQ2FtbqRLbIFT8FcsukpxTird49ax7oUXP+gzqxM/VdxHjfksQvzLBjLZyMdDStc5g7xA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@pinojs/redact": { "node_modules/@pinojs/redact": {
@@ -11985,7 +11985,7 @@
}, },
"packages/electron-app": { "packages/electron-app": {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@codenomad/ui": "file:../ui", "@codenomad/ui": "file:../ui",
@@ -12021,7 +12021,7 @@
}, },
"packages/server": { "packages/server": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.1", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
@@ -12062,7 +12062,7 @@
}, },
"packages/tauri-app": { "packages/tauri-app": {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.1", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.9.4" "@tauri-apps/cli": "^2.9.4"
@@ -12070,12 +12070,12 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.1", "version": "0.11.5",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11", "@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -12092,7 +12092,8 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0" "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,6 +1,6 @@
{ {
"name": "codenomad-workspace", "name": "codenomad-workspace",
"version": "0.11.1", "version": "0.11.5",
"private": true, "private": true,
"description": "CodeNomad monorepo workspace", "description": "CodeNomad monorepo workspace",
"license": "MIT", "license": "MIT",

View File

@@ -1,4 +1,4 @@
{ {
"minServerVersion": "0.11.1", "minServerVersion": "0.11.4",
"latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest" "latestServerUrl": "https://github.com/NeuralNomadsAI/CodeNomad/releases/latest"
} }

View File

@@ -431,7 +431,9 @@ export class CliProcessManager extends EventEmitter {
if (options.dev) { if (options.dev) {
const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000" const devServer = process.env.VITE_DEV_SERVER_URL || process.env.ELECTRON_RENDERER_URL || "http://localhost:3000"
args.push("--ui-dev-server", devServer, "--log-level", "debug") const rawLogLevel = (process.env.CLI_LOG_LEVEL ?? "info").trim()
const logLevel = rawLogLevel.length > 0 ? rawLogLevel.toLowerCase() : "info"
args.push("--ui-dev-server", devServer, "--log-level", logLevel)
} }
return args return args

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad-electron-app", "name": "@neuralnomads/codenomad-electron-app",
"version": "0.11.1", "version": "0.11.5",
"description": "CodeNomad - AI coding assistant", "description": "CodeNomad - AI coding assistant",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -15,7 +15,10 @@
}, },
"homepage": "https://github.com/NeuralNomadsAI/CodeNomad", "homepage": "https://github.com/NeuralNomadsAI/CodeNomad",
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "npm run dev:info",
"dev:info": "cross-env CLI_LOG_LEVEL=info electron-vite dev",
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts", "dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
"build": "electron-vite build", "build": "electron-vite build",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
@@ -42,6 +45,7 @@
"devDependencies": { "devDependencies": {
"7zip-bin": "^5.2.0", "7zip-bin": "^5.2.0",
"app-builder-bin": "^4.2.0", "app-builder-bin": "^4.2.0",
"cross-env": "^7.0.3",
"electron": "39.0.0", "electron": "39.0.0",
"electron-builder": "^24.0.0", "electron-builder": "^24.0.0",
"electron-vite": "4.0.1", "electron-vite": "4.0.1",

View File

@@ -4,6 +4,6 @@
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "1.2.4" "@opencode-ai/plugin": "1.2.14"
} }
} }

View File

@@ -5,18 +5,21 @@
## Features & Capabilities ## Features & Capabilities
### 🌍 Deployment Freedom ### 🌍 Deployment Freedom
- **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop. - **Remote Access**: Host CodeNomad on a powerful workstation and access it from your lightweight laptop.
- **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling. - **Code Anywhere**: Tunnel in via VPN or SSH to code securely from coffee shops or while traveling.
- **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal. - **Multi-Device**: The responsive web client works on tablets and iPads, turning any screen into a dev terminal.
- **Always-On**: Run as a background service so your sessions are always ready when you connect. - **Always-On**: Run as a background service so your sessions are always ready when you connect.
### ⚡️ Workspace Power ### ⚡️ Workspace Power
- **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs. - **Multi-Instance**: Juggle multiple OpenCode sessions side-by-side with per-instance tabs.
- **Long-Context Native**: Scroll through massive transcripts without hitches. - **Long-Context Native**: Scroll through massive transcripts without hitches.
- **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow. - **Deep Task Awareness**: Monitor background tasks and child sessions without losing your flow.
- **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts. - **Command Palette**: A single, global palette to jump tabs, launch tools, and fire shortcuts.
## Prerequisites ## Prerequisites
- **OpenCode**: `opencode` must be installed and configured on your system. - **OpenCode**: `opencode` must be installed and configured on your system.
- Node.js 18+ and npm (for running or building from source). - Node.js 18+ and npm (for running or building from source).
- A workspace folder on disk you want to serve. - A workspace folder on disk you want to serve.
@@ -25,6 +28,7 @@
## Usage ## Usage
### Run via npx (Recommended) ### Run via npx (Recommended)
You can run CodeNomad directly without installing it: You can run CodeNomad directly without installing it:
```sh ```sh
@@ -43,6 +47,7 @@ On startup, CodeNomad prints two URLs:
- `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled) - `Remote Connection URL : ...` (used by browsers/other machines when remote access is enabled)
### Install Globally ### Install Globally
Or install it globally to use the `codenomad` command: Or install it globally to use the `codenomad` command:
```sh ```sh
@@ -51,6 +56,7 @@ codenomad --launch
``` ```
### Install Locally (per-project) ### Install Locally (per-project)
If you prefer to install CodeNomad into a project and run the local binary: If you prefer to install CodeNomad into a project and run the local binary:
```sh ```sh
@@ -61,6 +67,7 @@ npx codenomad --launch
(`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.) (`npx codenomad ...` will use `./node_modules/.bin/codenomad` when present.)
### Common Flags ### Common Flags
You can configure the server using flags or environment variables: You can configure the server using flags or environment variables:
| Flag | Env Variable | Description | | Flag | Env Variable | Description |
@@ -74,7 +81,7 @@ You can configure the server using flags or environment variables:
| `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) | | `--tls-ca <path>` | `CLI_TLS_CA` | Optional CA chain/bundle (PEM) |
| `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) | | `--tlsSANs <list>` | `CLI_TLS_SANS` | Additional TLS SANs (comma-separated) |
| `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) | | `--host <addr>` | `CLI_HOST` | Interface to bind (default 127.0.0.1) |
| `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Default root for new workspaces | | `--workspace-root <path>` | `CLI_WORKSPACE_ROOT` | Restricts the root path where new workspaces can be opened. Git worktrees are created in `.codenomad/worktrees` inside the project folder. |
| `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing | | `--unrestricted-root` | `CLI_UNRESTRICTED_ROOT` | Allow full-filesystem browsing |
| `--config <path>` | `CLI_CONFIG` | Config file location | | `--config <path>` | `CLI_CONFIG` | Config file location |
| `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser | | `--launch` | `CLI_LAUNCH` | Open the UI in a Chromium-based browser |
@@ -87,10 +94,11 @@ You can configure the server using flags or environment variables:
| `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle | | `--ui-dir <path>` | `CLI_UI_DIR` | Directory containing the built UI bundle |
| `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) | | `--ui-dev-server <url>` | `CLI_UI_DEV_SERVER` | Proxy UI requests to a running dev server (requires `--https=false --http=true`) |
| `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates | | `--ui-no-update` | `CLI_UI_NO_UPDATE` | Disable remote UI updates |
| `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (true|false) | | `--ui-auto-update <enabled>` | `CLI_UI_AUTO_UPDATE` | Enable remote UI updates (`true` |
| `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL | | `--ui-manifest-url <url>` | `CLI_UI_MANIFEST_URL` | Remote UI manifest URL |
### Dev Releases (Advanced) ### Dev Releases (Advanced)
If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package: If you want the latest bleeding-edge builds (published as GitHub pre-releases), use the dev package:
```sh ```sh
@@ -141,12 +149,14 @@ codenomad --tlsSANs "localhost,127.0.0.1,my-hostname,192.168.1.10"
``` ```
### Authentication ### Authentication
- Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser. - Default behavior: CodeNomad requires a login (username/password) and stores a session cookie in the browser.
- `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated. - `--dangerously-skip-auth` / `CODENOMAD_SKIP_AUTH=true` disables the login prompt and treats all requests as authenticated.
Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.). Use this only when access is already protected by another layer (SSO proxy, VPN, Coder workspace auth, etc.).
If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API. If you bind to `0.0.0.0` while skipping auth, anyone who can reach the port can access the API.
### Progressive Web App (PWA) ### Progressive Web App (PWA)
When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead. When running as a server CodeNomad can also be installed as a PWA from any supported browser, giving you a native app experience just like the Electron installation but executing on the remote server instead.
1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.). 1. Open the CodeNomad UI in a Chromium-based browser (Chrome, Edge, Brave, etc.).
@@ -158,5 +168,6 @@ When running as a server CodeNomad can also be installed as a PWA from any suppo
> If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA). > If you host CodeNomad on a remote machine, use HTTPS. Self-signed certificates generally won't work unless they are explicitly trusted by the device/browser (e.g., via a custom CA).
### Data Storage ### Data Storage
- **Config**: `~/.config/codenomad/config.json` - **Config**: `~/.config/codenomad/config.json`
- **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.) - **Instance Data**: `~/.config/codenomad/instances` (chat history, etc.)

View File

@@ -1,12 +1,12 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.1", "version": "0.11.5",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.1", "version": "0.11.5",
"dependencies": { "dependencies": {
"@fastify/cors": "^8.5.0", "@fastify/cors": "^8.5.0",
"@fastify/reply-from": "^9.8.0", "@fastify/reply-from": "^9.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@neuralnomads/codenomad", "name": "@neuralnomads/codenomad",
"version": "0.11.1", "version": "0.11.5",
"description": "CodeNomad Server", "description": "CodeNomad Server",
"license": "MIT", "license": "MIT",
"author": { "author": {

View File

@@ -78,7 +78,7 @@ function parseCliOptions(argv: string[]): CliOptions {
.addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA")) .addOption(new Option("--tls-ca <path>", "TLS CA chain (PEM)").env("CLI_TLS_CA"))
.addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS")) .addOption(new Option("--tlsSANs <list>", "Additional TLS SANs (comma-separated)").env("CLI_TLS_SANS"))
.addOption( .addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()), new Option("--workspace-root <path>", "Restricts root path where workspaces can be opened").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
) )
.addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true)) .addOption(new Option("--root <path>").env("CLI_ROOT").hideHelp(true))
.addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false))

View File

@@ -367,6 +367,21 @@ function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDe
const INSTANCE_PROXY_HOST = "127.0.0.1" const INSTANCE_PROXY_HOST = "127.0.0.1"
// Special-case OpenCode directory override.
//
// UI clients may need to scope certain requests to an arbitrary directory that is not
// part of the Git worktree list. Since the OpenCode SDK does not reliably support
// injecting per-request headers, we encode an override into the *path* and strip it
// before proxying to the instance.
//
// Example proxied request path:
// /workspaces/:id/worktrees/:slug/instance/__dir/<base64url>/session/create
//
// The server will decode <base64url> -> absolute directory, validate it, then set
// x-opencode-directory accordingly and forward the request to /session/create.
const OPENCODE_DIR_OVERRIDE_PREFIX = "__dir/"
const OPENCODE_DIR_OVERRIDE_MAX_LEN = 4096
async function proxyWorkspaceRequest(args: { async function proxyWorkspaceRequest(args: {
request: FastifyRequest request: FastifyRequest
reply: FastifyReply reply: FastifyReply
@@ -457,19 +472,43 @@ async function proxyWorkspaceRequest(args: {
return return
} }
const directory = await resolveWorktreeDirectory({ let extracted: { overrideDirectory: string | null; forwardedSuffix: string | undefined }
workspaceId, try {
workspacePath: workspace.path, extracted = extractOpencodeDirectoryOverride(args.pathSuffix)
worktreeSlug, } catch (error) {
logger, const message = error instanceof Error ? error.message : "Invalid directory override"
}) reply.code(400).send({ error: message })
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return return
} }
let directory: string | null = null
let forwardedSuffix = extracted.forwardedSuffix
const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) if (extracted.overrideDirectory) {
try {
directory = validateAndNormalizeOverrideDirectory({
overrideDirectory: extracted.overrideDirectory,
workspaceRoot: workspace.path,
})
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid directory override"
reply.code(400).send({ error: message })
return
}
} else {
directory = await resolveWorktreeDirectory({
workspaceId,
workspacePath: workspace.path,
worktreeSlug,
logger,
})
if (!directory) {
reply.code(404).send({ error: "Worktree not found" })
return
}
}
const normalizedSuffix = normalizeInstanceSuffix(forwardedSuffix)
const queryIndex = (request.raw.url ?? "").indexOf("?") const queryIndex = (request.raw.url ?? "").indexOf("?")
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : ""
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`
@@ -533,6 +572,89 @@ async function proxyWorkspaceRequest(args: {
}) })
} }
function extractOpencodeDirectoryOverride(pathSuffix: string | undefined): {
overrideDirectory: string | null
forwardedSuffix: string | undefined
} {
if (!pathSuffix) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
// Fastify wildcard param does not include a leading slash.
const trimmed = pathSuffix.replace(/^\/+/, "")
if (!trimmed.startsWith(OPENCODE_DIR_OVERRIDE_PREFIX)) {
return { overrideDirectory: null, forwardedSuffix: pathSuffix }
}
const rest = trimmed.slice(OPENCODE_DIR_OVERRIDE_PREFIX.length)
const slashIndex = rest.indexOf("/")
const encoded = (slashIndex >= 0 ? rest.slice(0, slashIndex) : rest).trim()
const remaining = slashIndex >= 0 ? rest.slice(slashIndex + 1) : ""
if (!encoded) {
throw new Error("Missing directory override")
}
if (encoded.length > OPENCODE_DIR_OVERRIDE_MAX_LEN) {
throw new Error("Directory override too large")
}
let overrideDirectory = ""
try {
overrideDirectory = decodeBase64Url(encoded)
} catch {
throw new Error("Invalid directory override")
}
const forwardedSuffix = remaining
return { overrideDirectory, forwardedSuffix }
}
function decodeBase64Url(input: string): string {
// base64url -> base64
const normalized = input.replace(/-/g, "+").replace(/_/g, "/")
const padding = normalized.length % 4 === 0 ? "" : "=".repeat(4 - (normalized.length % 4))
const base64 = `${normalized}${padding}`
return Buffer.from(base64, "base64").toString("utf-8")
}
function validateAndNormalizeOverrideDirectory(params: { overrideDirectory: string; workspaceRoot: string }): string {
const raw = params.overrideDirectory.trim()
if (!raw) {
throw new Error("Override directory is empty")
}
if (!path.isAbsolute(raw)) {
throw new Error("Override directory must be an absolute path")
}
if (!fs.existsSync(raw)) {
throw new Error(`Override directory does not exist: ${raw}`)
}
const stats = fs.statSync(raw)
if (!stats.isDirectory()) {
throw new Error(`Override path is not a directory: ${raw}`)
}
const normalizedOverride = fs.realpathSync(raw)
const normalizedRoot = fs.realpathSync(params.workspaceRoot)
if (!isSubpath(normalizedOverride, normalizedRoot)) {
throw new Error("Override directory must be within the workspace root")
}
return normalizedOverride
}
function isSubpath(candidate: string, root: string): boolean {
const rel = path.relative(root, candidate)
if (rel === "") return true
if (rel === "..") return false
if (rel.startsWith(`..${path.sep}`)) return false
if (path.isAbsolute(rel)) return false
return true
}
function normalizeInstanceSuffix(pathSuffix: string | undefined) { function normalizeInstanceSuffix(pathSuffix: string | undefined) {
if (!pathSuffix || pathSuffix === "/") { if (!pathSuffix || pathSuffix === "/") {
return "/" return "/"

View File

@@ -119,7 +119,8 @@
showError(message || `Login failed (${res.status})`) showError(message || `Login failed (${res.status})`)
return return
} }
window.location.href = "/" // Replace history entry so Back doesn't return to /login.
window.location.replace("/")
} catch (e) { } catch (e) {
showError(e && e.message ? e.message : String(e)) showError(e && e.message ? e.message : String(e))
} }

View File

@@ -51,7 +51,19 @@ function getTokenHtml(): string {
} }
export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/login", async (_request, reply) => { app.get("/login", async (request, reply) => {
// If already authenticated, don't show the login page.
const session = deps.authManager.getSessionFromRequest(request)
if (session) {
reply.redirect("/")
return
}
// Avoid caching the login page (helps with bfcache/back behavior).
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
const status = deps.authManager.getStatus() const status = deps.authManager.getStatus()
reply.type("text/html").send(getLoginHtml(status.username)) reply.type("text/html").send(getLoginHtml(status.username))
}) })
@@ -67,6 +79,11 @@ export function registerAuthRoutes(app: FastifyInstance, deps: RouteDeps) {
return return
} }
// Avoid caching the token bootstrap page.
reply.header("Cache-Control", "no-store")
reply.header("Pragma", "no-cache")
reply.header("Expires", "0")
reply.type("text/html").send(getTokenHtml()) reply.type("text/html").send(getTokenHtml())
}) })

View File

@@ -1,7 +1,6 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { z } from "zod" import { z } from "zod"
import { spawnSync } from "child_process" import { probeBinaryVersion } from "../../workspaces/runtime"
import { buildSpawnSpec } from "../../workspaces/runtime"
import type { SettingsService } from "../../settings/service" import type { SettingsService } from "../../settings/service"
import type { Logger } from "../../logger" import type { Logger } from "../../logger"
@@ -15,37 +14,8 @@ const ValidateBinarySchema = z.object({
}) })
function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } { function validateBinaryPath(binaryPath: string): { valid: boolean; version?: string; error?: string } {
if (!binaryPath) { const result = probeBinaryVersion(binaryPath)
return { valid: false, error: "Missing binary path" } return { valid: result.valid, version: result.version, error: result.error }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdout = (result.stdout ?? "").trim()
const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0)
const normalized = firstLine?.trim()
const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
const version = versionMatch?.[1]
return { valid: true, version }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
} }
export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerSettingsRoutes(app: FastifyInstance, deps: RouteDeps) {

View File

@@ -109,10 +109,6 @@ export class WorkspaceManager {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
} }
if (!descriptor.binaryVersion) {
descriptor.binaryVersion = this.detectBinaryVersion(resolvedBinaryPath)
}
this.workspaces.set(id, descriptor) this.workspaces.set(id, descriptor)
@@ -149,7 +145,10 @@ export class WorkspaceManager {
onExit: (info) => this.handleProcessExit(info.workspaceId, info), onExit: (info) => this.handleProcessExit(info.workspaceId, info),
}) })
await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput }) const runtimeVersion = await this.waitForWorkspaceReadiness({ workspaceId: id, port, exitPromise, getLastOutput })
if (runtimeVersion) {
descriptor.binaryVersion = runtimeVersion
}
descriptor.pid = pid descriptor.pid = pid
descriptor.port = port descriptor.port = port
@@ -278,42 +277,12 @@ export class WorkspaceManager {
return candidates[0] ?? "" return candidates[0] ?? ""
} }
private detectBinaryVersion(resolvedPath: string): string | undefined {
if (!resolvedPath) {
return undefined
}
try {
const result = spawnSync(resolvedPath, ["--version"], { encoding: "utf8" })
if (result.status === 0 && result.stdout) {
const line = result.stdout.split(/\r?\n/).find((entry) => entry.trim().length > 0)
if (line) {
const normalized = line.trim()
const versionMatch = normalized.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/)
if (versionMatch) {
const version = versionMatch[1]
this.options.logger.debug({ binary: resolvedPath, version }, "Detected binary version")
return version
}
this.options.logger.debug({ binary: resolvedPath, reported: normalized }, "Binary reported version string")
return normalized
}
} else if (result.error) {
this.options.logger.warn({ binary: resolvedPath, err: result.error }, "Failed to read binary version")
}
} catch (error) {
this.options.logger.warn({ binary: resolvedPath, err: error }, "Failed to detect binary version")
}
return undefined
}
private async waitForWorkspaceReadiness(params: { private async waitForWorkspaceReadiness(params: {
workspaceId: string workspaceId: string
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}) { }): Promise<string | undefined> {
await Promise.race([ await Promise.race([
this.waitForPortAvailability(params.port), this.waitForPortAvailability(params.port),
@@ -327,7 +296,7 @@ export class WorkspaceManager {
}), }),
]) ])
await this.waitForInstanceHealth(params) const version = await this.waitForInstanceHealth(params)
await Promise.race([ await Promise.race([
this.delay(STARTUP_STABILITY_DELAY_MS), this.delay(STARTUP_STABILITY_DELAY_MS),
@@ -340,6 +309,8 @@ export class WorkspaceManager {
) )
}), }),
]) ])
return version
} }
private async waitForInstanceHealth(params: { private async waitForInstanceHealth(params: {
@@ -347,7 +318,7 @@ export class WorkspaceManager {
port: number port: number
exitPromise: Promise<ProcessExitInfo> exitPromise: Promise<ProcessExitInfo>
getLastOutput: () => string getLastOutput: () => string
}) { }): Promise<string | undefined> {
const probeResult = await Promise.race([ const probeResult = await Promise.race([
this.probeInstance(params.workspaceId, params.port), this.probeInstance(params.workspaceId, params.port),
params.exitPromise.then((info) => { params.exitPromise.then((info) => {
@@ -361,7 +332,7 @@ export class WorkspaceManager {
]) ])
if (probeResult.ok) { if (probeResult.ok) {
return return probeResult.version
} }
const latestOutput = params.getLastOutput().trim() const latestOutput = params.getLastOutput().trim()
@@ -372,8 +343,11 @@ export class WorkspaceManager {
throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`) throw new Error(`Workspace ${params.workspaceId} failed health check: ${reason}.`)
} }
private async probeInstance(workspaceId: string, port: number): Promise<{ ok: boolean; reason?: string }> { private async probeInstance(
const url = `http://127.0.0.1:${port}/project/current` workspaceId: string,
port: number,
): Promise<{ ok: boolean; reason?: string; version?: string }> {
const url = `http://127.0.0.1:${port}/global/health`
try { try {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -384,11 +358,22 @@ export class WorkspaceManager {
const response = await fetch(url, { headers }) const response = await fetch(url, { headers })
if (!response.ok) { if (!response.ok) {
const reason = `health probe returned HTTP ${response.status}` const reason = `/global/health returned HTTP ${response.status}`
this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error") this.options.logger.debug({ workspaceId, status: response.status }, "Health probe returned server error")
return { ok: false, reason } return { ok: false, reason }
} }
return { ok: true }
const payload = (await response.json().catch(() => null)) as null | { healthy?: unknown; version?: unknown }
const healthy = payload?.healthy === true
const version = typeof payload?.version === "string" ? payload.version.trim() : undefined
if (!healthy) {
const reason = "Instance reported unhealthy"
this.options.logger.debug({ workspaceId, payload }, "Health probe returned unhealthy response")
return { ok: false, reason }
}
return { ok: true, version: version || undefined }
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : String(error) const reason = error instanceof Error ? error.message : String(error)
this.options.logger.debug({ workspaceId, err: error }, "Health probe failed") this.options.logger.debug({ workspaceId, err: error }, "Health probe failed")

View File

@@ -8,6 +8,8 @@ import { Logger } from "../logger"
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]) export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"]) export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
export function buildSpawnSpec(binaryPath: string, args: string[]) { export function buildSpawnSpec(binaryPath: string, args: string[]) {
if (process.platform !== "win32") { if (process.platform !== "win32") {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
@@ -40,6 +42,61 @@ export function buildSpawnSpec(binaryPath: string, args: string[]) {
return { command: binaryPath, args, options: {} as const } return { command: binaryPath, args, options: {} as const }
} }
export function probeBinaryVersion(binaryPath: string): {
valid: boolean
version?: string
reported?: string
error?: string
} {
if (!binaryPath) {
return { valid: false, error: "Missing binary path" }
}
const spec = buildSpawnSpec(binaryPath, ["--version"])
try {
const result = spawnSync(spec.command, spec.args, {
encoding: "utf8",
windowsVerbatimArguments: Boolean(
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
),
})
if (result.error) {
return { valid: false, error: result.error.message }
}
if (result.status !== 0) {
const stderr = result.stderr?.trim()
const stdout = result.stdout?.trim()
const combined = stderr || stdout
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
return { valid: false, error }
}
const stdoutLines = String(result.stdout ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const stderrLines = String(result.stderr ?? "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
// Prefer stdout; fall back to stderr (some tools report version there).
const reported = stdoutLines[0] ?? stderrLines[0]
if (!reported) {
return { valid: true }
}
const versionMatch = reported.match(VERSION_REGEX)
const version = versionMatch?.[1]
return { valid: true, version, reported }
} catch (error) {
return { valid: false, error: error instanceof Error ? error.message : String(error) }
}
}
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> { function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/tauri-app", "name": "@codenomad/tauri-app",
"version": "0.11.1", "version": "0.11.5",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@codenomad/ui", "name": "@codenomad/ui",
"version": "0.11.1", "version": "0.11.5",
"private": true, "private": true,
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -13,7 +13,7 @@
"dependencies": { "dependencies": {
"@git-diff-view/solid": "^0.0.8", "@git-diff-view/solid": "^0.0.8",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "1.1.11", "@opencode-ai/sdk": "1.2.6",
"@solidjs/router": "^0.13.0", "@solidjs/router": "^0.13.0",
"@suid/icons-material": "^0.9.0", "@suid/icons-material": "^0.9.0",
"@suid/material": "^0.19.0", "@suid/material": "^0.19.0",
@@ -30,7 +30,8 @@
"shiki": "^3.13.0", "shiki": "^3.13.0",
"solid-js": "^1.8.0", "solid-js": "^1.8.0",
"solid-toast": "^0.5.0", "solid-toast": "^0.5.0",
"tauri-plugin-keepawake-api": "^0.1.0" "tauri-plugin-keepawake-api": "^0.1.0",
"yaml": "^2.4.2"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",

View File

@@ -1,6 +1,8 @@
import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js" import { Component, For, Show, createMemo, createEffect, createSignal, onMount, onCleanup } from "solid-js"
import { Dialog } from "@kobalte/core/dialog" import { Dialog } from "@kobalte/core/dialog"
import { Toaster } from "solid-toast" import { Toaster } from "solid-toast"
import useMediaQuery from "@suid/material/useMediaQuery"
import { Minimize2 } from "lucide-solid"
import AlertDialog from "./components/alert-dialog" import AlertDialog from "./components/alert-dialog"
import FolderSelectionView from "./components/folder-selection-view" import FolderSelectionView from "./components/folder-selection-view"
import { showConfirmDialog } from "./stores/alerts" import { showConfirmDialog } from "./stores/alerts"
@@ -16,6 +18,8 @@ import { useTheme } from "./lib/theme"
import { useCommands } from "./lib/hooks/use-commands" import { useCommands } from "./lib/hooks/use-commands"
import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle"
import { getLogger } from "./lib/logger" import { getLogger } from "./lib/logger"
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
import { initReleaseNotifications } from "./stores/releases" import { initReleaseNotifications } from "./stores/releases"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { useI18n } from "./lib/i18n" import { useI18n } from "./lib/i18n"
@@ -70,18 +74,53 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility,
} = useConfig() } = useConfig()
const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [escapeInDebounce, setEscapeInDebounce] = createSignal(false)
interface LaunchErrorState {
message: string
binaryPath: string
missingBinary: boolean
}
const [launchError, setLaunchError] = createSignal<LaunchErrorState | null>(null)
const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false)
const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false)
const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0) const [instanceTabBarHeight, setInstanceTabBarHeight] = createSignal(0)
const phoneQuery = useMediaQuery("(max-width: 767px)")
const isPhoneLayout = createMemo(() => phoneQuery())
// In-memory only: hides chrome on phone; may also request browser fullscreen.
const [mobileFullscreenMode, setMobileFullscreenMode] = createSignal(false)
const [browserFullscreenActive, setBrowserFullscreenActive] = createSignal(false)
const fullscreenSupported = () => {
if (typeof document === "undefined") return false
const el = document.documentElement as any
return Boolean(document.fullscreenEnabled) && typeof el?.requestFullscreen === "function"
}
const syncBrowserFullscreenState = () => {
if (typeof document === "undefined") return
setBrowserFullscreenActive(Boolean(document.fullscreenElement))
}
const enterMobileFullscreen = async () => {
if (!isPhoneLayout()) return
setMobileFullscreenMode(true)
if (!fullscreenSupported()) return
try {
await document.documentElement.requestFullscreen()
} catch {
// Ignore: immersive mode still works without browser fullscreen.
}
}
const exitMobileFullscreen = async () => {
if (typeof document !== "undefined" && document.fullscreenElement && typeof document.exitFullscreen === "function") {
try {
await document.exitFullscreen()
} catch {
// Ignore
}
}
setMobileFullscreenMode(false)
}
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
const shouldShow = const shouldShow =
@@ -95,6 +134,56 @@ const App: Component = () => {
setInstanceTabBarHeight(element?.offsetHeight ?? 0) setInstanceTabBarHeight(element?.offsetHeight ?? 0)
} }
onMount(() => {
if (typeof document === "undefined") return
syncBrowserFullscreenState()
document.addEventListener("fullscreenchange", syncBrowserFullscreenState)
onCleanup(() => document.removeEventListener("fullscreenchange", syncBrowserFullscreenState))
})
onMount(() => {
if (typeof window === "undefined") return
const vv = window.visualViewport
if (!vv) return
const updateKeyboardOffset = () => {
// visualViewport shrinks when the OSK is visible. Use the delta as a bottom inset.
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop)
document.documentElement.style.setProperty("--keyboard-offset", `${Math.floor(inset)}px`)
}
const schedule = () => requestAnimationFrame(updateKeyboardOffset)
schedule()
vv.addEventListener("resize", schedule)
vv.addEventListener("scroll", schedule)
window.addEventListener("orientationchange", schedule)
onCleanup(() => {
vv.removeEventListener("resize", schedule)
vv.removeEventListener("scroll", schedule)
window.removeEventListener("orientationchange", schedule)
document.documentElement.style.removeProperty("--keyboard-offset")
})
})
// If the user exits browser fullscreen via browser UI, restore chrome.
let lastBrowserFullscreen = false
createEffect(() => {
const active = browserFullscreenActive()
const mode = mobileFullscreenMode()
if (mode && lastBrowserFullscreen && !active) {
setMobileFullscreenMode(false)
}
lastBrowserFullscreen = active
})
// If we leave phone layout (rotation / resize), restore chrome.
createEffect(() => {
if (!isPhoneLayout() && mobileFullscreenMode()) {
void exitMobileFullscreen()
}
})
createEffect(() => { createEffect(() => {
void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error)) void initMarkdown(isDark()).catch((error) => log.error("Failed to initialize markdown", error))
}) })
@@ -152,35 +241,6 @@ const App: Component = () => {
const launchErrorMessage = () => launchError()?.message ?? "" const launchErrorMessage = () => launchError()?.message ?? ""
const formatLaunchErrorMessage = (error: unknown): string => {
if (!error) {
return t("app.launchError.fallbackMessage")
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed.error === "string") {
return parsed.error
}
} catch {
// ignore JSON parse errors
}
return raw
}
const isMissingBinaryMessage = (message: string): boolean => {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}
const clearLaunchError = () => setLaunchError(null)
async function handleSelectFolder(folderPath: string, binaryPath?: string) { async function handleSelectFolder(folderPath: string, binaryPath?: string) {
if (!folderPath) { if (!folderPath) {
return return
@@ -199,13 +259,9 @@ const App: Component = () => {
port: instances().get(instanceId)?.port, port: instances().get(instanceId)?.port,
}) })
} catch (error) { } catch (error) {
const message = formatLaunchErrorMessage(error) const message = formatLaunchErrorMessage(error, t("app.launchError.fallbackMessage"))
const missingBinary = isMissingBinaryMessage(message) const missingBinary = isMissingBinaryMessage(message)
setLaunchError({ showLaunchError({ source: "create", message, binaryPath: selectedBinary, missingBinary })
message,
binaryPath: selectedBinary,
missingBinary,
})
log.error("Failed to create instance", error) log.error("Failed to create instance", error)
} finally { } finally {
setIsSelectingFolder(false) setIsSelectingFolder(false)
@@ -310,6 +366,7 @@ const App: Component = () => {
setToolOutputExpansion, setToolOutputExpansion,
setDiagnosticsExpansion, setDiagnosticsExpansion,
setThinkingBlocksExpansion, setThinkingBlocksExpansion,
setToolInputsVisibility,
handleNewInstanceRequest, handleNewInstanceRequest,
handleCloseInstance, handleCloseInstance,
handleNewSession, handleNewSession,
@@ -405,19 +462,34 @@ const App: Component = () => {
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
<div class="h-screen w-screen flex flex-col"> <div class="h-screen w-screen flex flex-col" style={{ height: "100dvh", "padding-bottom": "var(--keyboard-offset, 0px)" }}>
<Show when={isPhoneLayout() && mobileFullscreenMode()}>
<div class="mobile-fullscreen-exit-wrapper">
<button
type="button"
class="message-scroll-button mobile-fullscreen-exit-button"
onClick={() => void exitMobileFullscreen()}
aria-label={t("instanceShell.fullscreen.exit")}
title={t("instanceShell.fullscreen.exit")}
>
<Minimize2 class="h-5 w-5" aria-hidden="true" />
</button>
</div>
</Show>
<Show <Show
when={!hasInstances()} when={!hasInstances()}
fallback={ fallback={
<> <>
<InstanceTabs <Show when={!isPhoneLayout() || !mobileFullscreenMode()}>
instances={instances()} <InstanceTabs
activeInstanceId={activeInstanceId()} instances={instances()}
onSelect={setActiveInstanceId} activeInstanceId={activeInstanceId()}
onClose={handleCloseInstance} onSelect={setActiveInstanceId}
onNew={handleNewInstanceRequest} onClose={handleCloseInstance}
onOpenRemoteAccess={() => setRemoteAccessOpen(true)} onNew={handleNewInstanceRequest}
/> onOpenRemoteAccess={() => setRemoteAccessOpen(true)}
/>
</Show>
<For each={Array.from(instances().values())}> <For each={Array.from(instances().values())}>
{(instance) => { {(instance) => {
@@ -435,7 +507,10 @@ const App: Component = () => {
handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)} handleSidebarAgentChange={(sessionId, agent) => handleSidebarAgentChange(instance.id, sessionId, agent)}
handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)} handleSidebarModelChange={(sessionId, model) => handleSidebarModelChange(instance.id, sessionId, model)}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}
tabBarOffset={instanceTabBarHeight()} tabBarOffset={isPhoneLayout() && mobileFullscreenMode() ? 0 : instanceTabBarHeight()}
mobileFullscreenMode={isPhoneLayout() && mobileFullscreenMode()}
onEnterMobileFullscreen={() => void enterMobileFullscreen()}
onExitMobileFullscreen={() => void exitMobileFullscreen()}
/> />
</InstanceMetadataProvider> </InstanceMetadataProvider>

View File

@@ -31,10 +31,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
const availableAgents = createMemo(() => { const availableAgents = createMemo(() => {
const allAgents = instanceAgents() const allAgents = instanceAgents()
if (isChildSession()) { if (isChildSession()) {
return allAgents return allAgents.filter((agent) => !agent.hidden)
} }
const filtered = allAgents.filter((agent) => agent.mode !== "subagent") const filtered = allAgents.filter((agent) => !agent.hidden && agent.mode !== "subagent")
const currentAgent = allAgents.find((a) => a.name === props.currentAgent) const currentAgent = allAgents.find((a) => a.name === props.currentAgent)
if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) { if (currentAgent && !filtered.find((a) => a.name === props.currentAgent)) {
@@ -103,10 +103,10 @@ export default function AgentSelector(props: AgentSelectorProps) {
> >
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Select.Value<Agent>> <Select.Value<Agent>>
{(state) => ( {() => (
<div class="selector-trigger-label selector-trigger-label--stacked"> <div class="selector-trigger-label selector-trigger-label--stacked">
<span class="selector-trigger-primary selector-trigger-primary--align-left"> <span class="selector-trigger-primary selector-trigger-primary--align-left">
{t("agentSelector.trigger.primary", { agent: state.selectedOption()?.name ?? t("agentSelector.none") })} {t("agentSelector.trigger.primary", { agent: props.currentAgent || t("agentSelector.none") })}
</span> </span>
</div> </div>
)} )}

View File

@@ -115,28 +115,28 @@ const AlertDialog: Component = () => {
> >
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="modal-overlay" /> <Dialog.Overlay class="modal-overlay" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}> <Dialog.Content class="modal-surface w-full max-w-sm p-6 border border-base shadow-2xl" tabIndex={-1}>
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div <div
class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold" class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border text-base font-semibold"
style={{ style={{
"background-color": accent.badgeBg, "background-color": accent.badgeBg,
"border-color": accent.badgeBorder, "border-color": accent.badgeBorder,
color: accent.badgeText, color: accent.badgeText,
}} }}
aria-hidden aria-hidden
> >
{accent.symbol} {accent.symbol}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title> <Dialog.Title class="text-lg font-semibold text-primary">{title}</Dialog.Title>
<Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words"> <Dialog.Description class="text-sm text-secondary mt-1 whitespace-pre-line break-words">
{payload.message} {payload.message}
{payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>} {payload.detail && <p class="mt-2 text-secondary">{payload.detail}</p>}
</Dialog.Description> </Dialog.Description>
</div> </div>
</div> </div>
<Show when={isPrompt}> <Show when={isPrompt}>
<div class="mt-4"> <div class="mt-4">
@@ -185,14 +185,14 @@ const AlertDialog: Component = () => {
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
</div> </div>
</Dialog.Portal> </Dialog.Portal>
</Dialog> </Dialog>
) )
}} }}
</Show> </Show>
) )
} }
export default AlertDialog export default AlertDialog

View File

@@ -0,0 +1,123 @@
import type { Component } from "solid-js"
interface ContextMeterProps {
usedTokens: number
availableTokens: number | null
formatTokens: (value: number) => string
usedLabel: string
availableLabel: string
class?: string
}
const LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function resolveFillColor(percent: number): string {
if (percent >= 0.8) return "var(--status-error)"
if (percent >= 0.6) return "var(--status-warning)"
return "var(--status-success)"
}
export const ContextMeter: Component<ContextMeterProps> = (props) => {
const hasAvailable = () => typeof props.availableTokens === "number" && props.availableTokens > 0
const used = () => (typeof props.usedTokens === "number" && props.usedTokens > 0 ? props.usedTokens : 0)
const available = () => (hasAvailable() ? (props.availableTokens as number) : null)
const percent = () => {
const usedValue = used()
const availableValue = available()
if (availableValue === null || availableValue <= 0) return null
// Heuristic: if available >= used, treat it like a capacity/limit.
// Otherwise treat it like remaining tokens.
const ratio = availableValue >= usedValue ? usedValue / availableValue : usedValue / (usedValue + availableValue)
return clamp(ratio, 0, 1)
}
const fillColor = () => {
const value = percent()
if (value === null) return "var(--border-base)"
return resolveFillColor(value)
}
const percentLabel = () => {
const value = percent()
if (value === null) return "--"
return `${Math.round(value * 100)}%`
}
const containerClass =
`inline-flex items-center gap-2 rounded-full border border-base px-2 py-0.5 text-xs text-primary ${props.class ?? ""}`
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = (angleDeg * Math.PI) / 180
return {
x: cx + r * Math.cos(rad),
y: cy + r * Math.sin(rad),
}
}
function describeSectorPath(cx: number, cy: number, r: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, r, startAngle)
const end = polarToCartesian(cx, cy, r, endAngle)
const delta = ((endAngle - startAngle) % 360 + 360) % 360
const largeArc = delta > 180 ? 1 : 0
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y} Z`
}
const circle = () => {
const value = percent()
const size = 22
const r = 9
const cx = 11
const cy = 11
const progress = value === null ? 0 : value
const startAngle = -90
const endAngle = startAngle + progress * 360
const isFull = progress >= 0.999
const hasFill = progress > 0.001
const sectorPath = hasFill && !isFull ? describeSectorPath(cx, cy, r, startAngle, endAngle) : null
return (
<svg
width={size}
height={size}
viewBox="0 0 22 22"
aria-hidden="true"
style={{ flex: "0 0 auto" }}
>
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="var(--surface-secondary)" />
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill="none" stroke="var(--border-base)" stroke-width="1" />
{isFull ? (
<circle cx={String(cx)} cy={String(cy)} r={String(r)} fill={fillColor()} opacity="0.95" />
) : sectorPath ? (
<path d={sectorPath} fill={fillColor()} opacity="0.95" />
) : null}
</svg>
)
}
const tooltipText = () => `Context Used: ${percentLabel()}`
return (
<div class="inline-flex items-center gap-2" title={tooltipText()}>
{circle()}
<div class={containerClass}>
<span class={LABEL_CLASS}>{props.usedLabel}</span>
<span class="font-semibold text-primary tabular-nums">{props.formatTokens(used())}</span>
<span class="text-muted">/</span>
<span class={LABEL_CLASS}>{props.availableLabel}</span>
<span class="font-semibold text-primary tabular-nums">
{available() !== null ? props.formatTokens(available() as number) : "--"}
</span>
</div>
</div>
)
}
export default ContextMeter

View File

@@ -12,6 +12,7 @@ interface MonacoDiffViewerProps {
after: string after: string
viewMode?: "split" | "unified" viewMode?: "split" | "unified"
contextMode?: "expanded" | "collapsed" contextMode?: "expanded" | "collapsed"
wordWrap?: "on" | "off"
} }
export function MonacoDiffViewer(props: MonacoDiffViewerProps) { export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
@@ -54,12 +55,17 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
renderWhitespace: "selection", renderWhitespace: "selection",
fontSize: 13, fontSize: 13,
wordWrap: "off", wordWrap: props.wordWrap === "on" ? "on" : "off",
glyphMargin: false, glyphMargin: false,
folding: false, folding: false,
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers. // Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
lineNumbersMinChars: 4, lineNumbersMinChars: 4,
lineDecorationsWidth: 12, lineDecorationsWidth: 12,
// Use legacy diff algorithm for better performance with large files
// See: https://github.com/microsoft/vscode/issues/184037
diffAlgorithm: "legacy",
// Limit computation time to avoid freezing on large files
maxComputationTime: 10000,
}) })
setReady(true) setReady(true)
@@ -81,6 +87,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
if (!ready() || !monaco || !diffEditor) return if (!ready() || !monaco || !diffEditor) return
const viewMode = props.viewMode === "unified" ? "unified" : "split" const viewMode = props.viewMode === "unified" ? "unified" : "split"
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded" const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
const wordWrap = props.wordWrap === "on" ? "on" : "off"
diffEditor.updateOptions({ diffEditor.updateOptions({
renderSideBySide: viewMode === "split", renderSideBySide: viewMode === "split",
@@ -89,7 +96,20 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
contextMode === "collapsed" contextMode === "collapsed"
? { enabled: true } ? { enabled: true }
: { enabled: false }, : { enabled: false },
wordWrap,
}) })
try {
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
try {
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
} catch {
// ignore
}
}) })
createEffect(() => { createEffect(() => {

View File

@@ -1,5 +1,5 @@
import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js" import { Component, For, createSignal, createEffect, Show, onMount, onCleanup, createMemo } from "solid-js"
import { instances, getInstanceLogs, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances" import { getInstanceLogs, instances, isInstanceLogStreaming, setInstanceLogStreaming } from "../stores/instances"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import InstanceInfo from "./instance-info" import InstanceInfo from "./instance-info"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
@@ -86,8 +86,8 @@ const InfoView: Component<InfoViewProps> = (props) => {
return ( return (
<div class="log-container"> <div class="log-container">
<div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden"> <div class="flex-1 flex flex-col lg:flex-row gap-4 p-4 overflow-hidden">
<div class="lg:w-80 flex-shrink-0 overflow-y-auto"> <div class="lg:w-80 flex-shrink-0 min-h-0 overflow-y-auto max-h-[40vh] lg:max-h-none">
<Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} />}</Show> <Show when={instance()}>{(inst) => <InstanceInfo instance={inst()} showDisposeButton />}</Show>
</div> </div>
<div class="panel flex-1 flex flex-col min-h-0 overflow-hidden"> <div class="panel flex-1 flex flex-col min-h-0 overflow-hidden">

View File

@@ -1,14 +1,21 @@
import { Component, For, Show, createMemo } from "solid-js" import { Component, For, Show, createMemo, createSignal } from "solid-js"
import type { Instance } from "../types/instance" import type { Instance } from "../types/instance"
import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context" import { useOptionalInstanceMetadataContext } from "../lib/contexts/instance-metadata-context"
import InstanceServiceStatus from "./instance-service-status" import InstanceServiceStatus from "./instance-service-status"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showConfirmDialog } from "../stores/alerts"
import { disposeInstance } from "../stores/instances"
import { showToastNotification } from "../lib/notifications"
import { getLogger } from "../lib/logger"
interface InstanceInfoProps { interface InstanceInfoProps {
instance: Instance instance: Instance
compact?: boolean compact?: boolean
showDisposeButton?: boolean
} }
const log = getLogger("actions")
const InstanceInfo: Component<InstanceInfoProps> = (props) => { const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const { t } = useI18n() const { t } = useI18n()
const metadataContext = useOptionalInstanceMetadataContext() const metadataContext = useOptionalInstanceMetadataContext()
@@ -16,6 +23,8 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
const instanceAccessor = metadataContext?.instance ?? (() => props.instance) const instanceAccessor = metadataContext?.instance ?? (() => props.instance)
const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata) const metadataAccessor = metadataContext?.metadata ?? (() => props.instance.metadata)
const [isDisposing, setIsDisposing] = createSignal(false)
const currentInstance = () => instanceAccessor() const currentInstance = () => instanceAccessor()
const metadata = () => metadataAccessor() const metadata = () => metadataAccessor()
const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version const binaryVersion = () => currentInstance().binaryVersion || metadata()?.version
@@ -25,6 +34,46 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
return env ? Object.entries(env) : [] return env ? Object.entries(env) : []
}) })
const disposeEnabled = createMemo(() => Boolean(currentInstance()?.client) && !isDisposing())
const handleDisposeInstance = async () => {
if (!disposeEnabled()) return
const confirmed = await showConfirmDialog(t("infoView.dispose.confirm.message"), {
title: t("infoView.dispose.confirm.title"),
variant: "warning",
confirmLabel: t("infoView.dispose.confirm.confirmLabel"),
cancelLabel: t("infoView.dispose.confirm.cancelLabel"),
})
if (!confirmed) return
setIsDisposing(true)
try {
const ok = await disposeInstance(currentInstance().id)
if (ok) {
showToastNotification({
message: t("infoView.dispose.toast.success"),
variant: "success",
duration: 8000,
})
} else {
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
}
} catch (error) {
log.error("Failed to dispose instance", error)
showToastNotification({
message: t("infoView.dispose.toast.error"),
variant: "error",
})
} finally {
setIsDisposing(false)
}
}
return ( return (
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -156,6 +205,19 @@ const InstanceInfo: Component<InstanceInfoProps> = (props) => {
</div> </div>
</div> </div>
</div> </div>
<Show when={props.showDisposeButton}>
<div class="pt-3 border-t border-base">
<button
type="button"
class="button-danger button-small w-full"
onClick={handleDisposeInstance}
disabled={!disposeEnabled()}
>
{isDisposing() ? t("infoView.dispose.actions.disposing") : t("infoView.dispose.actions.dispose")}
</button>
</div>
</Show>
</div> </div>
</div> </div>
) )

View File

@@ -29,6 +29,7 @@ import PermissionNotificationBanner from "../permission-notification-banner"
import PermissionApprovalModal from "../permission-approval-modal" import PermissionApprovalModal from "../permission-approval-modal"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
import ContextMeter from "../context-meter"
import { sseManager } from "../../lib/sse-manager" import { sseManager } from "../../lib/sse-manager"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { serverApi } from "../../lib/api-client" import { serverApi } from "../../lib/api-client"
@@ -41,7 +42,7 @@ import { useSessionSidebarRequests } from "./shell/useSessionSidebarRequests"
import RightPanel from "./shell/right-panel/RightPanel" import RightPanel from "./shell/right-panel/RightPanel"
import { useDrawerChrome } from "./shell/useDrawerChrome" import { useDrawerChrome } from "./shell/useDrawerChrome"
import { getSessionStatus } from "../../stores/session-status" import { getSessionStatus } from "../../stores/session-status"
import { ShieldAlert } from "lucide-solid" import { Maximize2, ShieldAlert } from "lucide-solid"
import type { LayoutMode } from "./shell/types" import type { LayoutMode } from "./shell/types"
import { import {
@@ -69,6 +70,11 @@ interface InstanceShellProps {
handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void> handleSidebarModelChange: (sessionId: string, model: { providerId: string; modelId: string }) => Promise<void>
onExecuteCommand: (command: Command) => void onExecuteCommand: (command: Command) => void
tabBarOffset: number tabBarOffset: number
// In-memory only: mobile immersive/fullscreen mode.
mobileFullscreenMode: boolean
onEnterMobileFullscreen: () => void
onExitMobileFullscreen: () => void
} }
const InstanceShell2: Component<InstanceShellProps> = (props) => { const InstanceShell2: Component<InstanceShellProps> = (props) => {
@@ -109,6 +115,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
const desktopQuery = useMediaQuery("(min-width: 1280px)") const desktopQuery = useMediaQuery("(min-width: 1280px)")
const tabletQuery = useMediaQuery("(min-width: 768px)") const tabletQuery = useMediaQuery("(min-width: 768px)")
const compactHeaderQuery = useMediaQuery("(max-width: 1024px)")
const layoutMode = createMemo<LayoutMode>(() => { const layoutMode = createMemo<LayoutMode>(() => {
if (desktopQuery()) return "desktop" if (desktopQuery()) return "desktop"
@@ -117,6 +124,9 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
}) })
const isPhoneLayout = createMemo(() => layoutMode() === "phone") const isPhoneLayout = createMemo(() => layoutMode() === "phone")
const compactHeaderLayout = createMemo(() => isPhoneLayout() || compactHeaderQuery())
const mobileFullscreen = createMemo(() => props.mobileFullscreenMode && isPhoneLayout())
const compactPromptLayout = createMemo(() => layoutMode() !== "desktop")
const leftPinningSupported = createMemo(() => layoutMode() !== "phone") const leftPinningSupported = createMemo(() => layoutMode() !== "phone")
const rightPinningSupported = createMemo(() => layoutMode() !== "phone") const rightPinningSupported = createMemo(() => layoutMode() !== "phone")
@@ -349,16 +359,6 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
measureDrawerHost, measureDrawerHost,
}) })
const formattedUsedTokens = () => formatTokenTotal(tokenStats().used)
const formattedAvailableTokens = () => {
const avail = tokenStats().avail
if (typeof avail === "number") {
return formatTokenTotal(avail)
}
return "--"
}
const renderLeftPanel = () => { const renderLeftPanel = () => {
if (leftPinned()) { if (leftPinned()) {
@@ -594,13 +594,14 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{renderLeftPanel()} {renderLeftPanel()}
<Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}> <Box sx={{ display: "flex", flexDirection: "column", flex: 1, minWidth: 0, minHeight: 0, overflowX: "hidden" }}>
<AppBar position="sticky" color="default" elevation={0} class="border-b border-base"> <Show when={!mobileFullscreen()}>
<Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]"> <AppBar position="sticky" color="default" elevation={0} class="border-b border-base">
<Show <Toolbar variant="dense" class="session-toolbar flex flex-wrap items-center gap-2 py-0 min-h-[40px]">
when={!isPhoneLayout()} <Show
fallback={ when={!compactHeaderLayout()}
<div class="flex flex-col w-full gap-1.5"> fallback={
<div class="flex flex-wrap items-center justify-between gap-2 w-full"> <div class="flex flex-col w-full gap-1.5">
<div class="flex flex-wrap items-center justify-between gap-2 w-full">
<Show when={leftDrawerState() === "floating-closed"}> <Show when={leftDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setLeftToggleButtonEl} ref={setLeftToggleButtonEl}
@@ -626,7 +627,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="flex flex-wrap items-center justify-center gap-1"> <div class="flex flex-wrap items-center justify-center gap-1">
<button <button
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button command-palette-button"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -635,8 +636,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</button> </button>
<span class="connection-status-shortcut-hint kbd-hint"> <span class="connection-status-shortcut-hint kbd-hint">
<Kbd shortcut="cmd+shift+p" /> <Kbd shortcut="cmd+shift+p" />
</span> </span>
</div> </div>
<div class="flex-1 flex items-center justify-center min-w-0"> <div class="flex-1 flex items-center justify-center min-w-0">
<span <span
@@ -647,6 +648,18 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</span> </span>
</div> </div>
<Show when={isPhoneLayout() && !props.mobileFullscreenMode}>
<IconButton
color="inherit"
onClick={props.onEnterMobileFullscreen}
aria-label={t("instanceShell.fullscreen.enter")}
title={t("instanceShell.fullscreen.enter")}
size="small"
>
<Maximize2 class="w-5 h-5" aria-hidden="true" />
</IconButton>
</Show>
<Show when={rightDrawerState() === "floating-closed"}> <Show when={rightDrawerState() === "floating-closed"}>
<IconButton <IconButton
ref={setRightToggleButtonEl} ref={setRightToggleButtonEl}
@@ -659,22 +672,19 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{rightAppBarButtonIcon()} {rightAppBarButtonIcon()}
</IconButton> </IconButton>
</Show> </Show>
</div> </div>
<div class="flex flex-wrap items-center justify-center gap-2 pb-1"> <div class="flex flex-wrap items-center justify-center gap-2 pb-1">
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <Show when={!showingInfoView()}>
<span class="uppercase text-[10px] tracking-wide text-muted"> <ContextMeter
{t("instanceShell.metrics.usedLabel")} usedTokens={tokenStats().used}
</span> availableTokens={tokenStats().avail}
<span class="font-semibold text-primary">{formattedUsedTokens()}</span> formatTokens={formatTokenTotal}
usedLabel={t("instanceShell.metrics.usedLabel")}
availableLabel={t("instanceShell.metrics.availableLabel")}
/>
</Show>
</div> </div>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary">
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</div>
</div> </div>
} }
> >
@@ -693,18 +703,13 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
<Show when={!showingInfoView()}> <Show when={!showingInfoView()}>
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> <ContextMeter
<span class="uppercase text-[10px] tracking-wide text-muted"> usedTokens={tokenStats().used}
{t("instanceShell.metrics.usedLabel")} availableTokens={tokenStats().avail}
</span> formatTokens={formatTokenTotal}
<span class="font-semibold text-primary">{formattedUsedTokens()}</span> usedLabel={t("instanceShell.metrics.usedLabel")}
</div> availableLabel={t("instanceShell.metrics.availableLabel")}
<div class="inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"> />
<span class="uppercase text-[10px] tracking-wide text-muted">
{t("instanceShell.metrics.availableLabel")}
</span>
<span class="font-semibold text-primary">{formattedAvailableTokens()}</span>
</div>
</Show> </Show>
<div class="ml-auto flex items-center session-header-hints"> <div class="ml-auto flex items-center session-header-hints">
@@ -720,7 +725,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
<div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]"> <div class="session-toolbar-center flex items-center justify-center gap-2 min-w-[160px]">
<button <button
type="button" type="button"
class="connection-status-button px-2 py-0.5 text-xs" class="connection-status-button command-palette-button"
onClick={handleCommandPaletteClick} onClick={handleCommandPaletteClick}
aria-label={t("instanceShell.commandPalette.openAriaLabel")} aria-label={t("instanceShell.commandPalette.openAriaLabel")}
style={{ flex: "0 0 auto", width: "auto" }} style={{ flex: "0 0 auto", width: "auto" }}
@@ -769,9 +774,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Show> </Show>
</div> </div>
</div> </div>
</Show> </Show>
</Toolbar> </Toolbar>
</AppBar> </AppBar>
</Show>
<Box <Box
component="main" component="main"
@@ -808,6 +814,8 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
instanceId={props.instance.id} instanceId={props.instance.id}
instanceFolder={props.instance.folder} instanceFolder={props.instance.folder}
escapeInDebounce={props.escapeInDebounce} escapeInDebounce={props.escapeInDebounce}
isPhoneLayout={isPhoneLayout()}
compactPromptLayout={compactPromptLayout()}
showSidebarToggle={showEmbeddedSidebarToggle()} showSidebarToggle={showEmbeddedSidebarToggle()}
onSidebarToggle={() => setLeftOpen(true)} onSidebarToggle={() => setLeftOpen(true)}
forceCompactStatusLayout={showEmbeddedSidebarToggle()} forceCompactStatusLayout={showEmbeddedSidebarToggle()}

View File

@@ -7,7 +7,7 @@ import {
type Accessor, type Accessor,
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client" import type { FileContent, FileNode, File as GitFileStatus } from "@opencode-ai/sdk/v2/client"
import IconButton from "@suid/material/IconButton" import IconButton from "@suid/material/IconButton"
import MenuOpenIcon from "@suid/icons-material/MenuOpen" import MenuOpenIcon from "@suid/icons-material/MenuOpen"
@@ -18,7 +18,7 @@ import type { Instance } from "../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../server/src/api-types"
import type { Session } from "../../../../types/session" import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types" import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, RightPanelTab } from "./types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab" import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab" import FilesTab from "./tabs/FilesTab"
@@ -32,6 +32,7 @@ import { useGlobalPointerDrag } from "../useGlobalPointerDrag"
import { import {
RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY, RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY,
RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_NONPHONE_KEY,
RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY, RIGHT_PANEL_CHANGES_LIST_OPEN_PHONE_KEY,
RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY, RIGHT_PANEL_CHANGES_SPLIT_WIDTH_KEY,
@@ -102,6 +103,9 @@ const RightPanel: Component<RightPanelProps> = (props) => {
const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>( const [diffContextMode, setDiffContextMode] = createSignal<DiffContextMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed", readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, ["expanded", "collapsed"] as const) ?? "collapsed",
) )
const [diffWordWrapMode, setDiffWordWrapMode] = createSignal<DiffWordWrapMode>(
readStoredEnum(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, ["on", "off"] as const) ?? "on",
)
const [changesSplitWidth, setChangesSplitWidth] = createSignal(320) const [changesSplitWidth, setChangesSplitWidth] = createSignal(320)
const [filesSplitWidth, setFilesSplitWidth] = createSignal(320) const [filesSplitWidth, setFilesSplitWidth] = createSignal(320)
@@ -195,6 +199,11 @@ const RightPanel: Component<RightPanelProps> = (props) => {
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode()) window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY, diffContextMode())
}) })
createEffect(() => {
if (typeof window === "undefined") return
window.localStorage.setItem(RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY, diffWordWrapMode())
})
const clampSplitWidth = (value: number) => { const clampSplitWidth = (value: number) => {
const min = 200 const min = 200
const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65)) const maxByDrawer = Math.max(min, Math.floor(props.rightDrawerWidth() * 0.65))
@@ -738,8 +747,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
onSelectFile={handleSelectChangesFile} onSelectFile={handleSelectChangesFile}
diffViewMode={diffViewMode} diffViewMode={diffViewMode}
diffContextMode={diffContextMode} diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
listOpen={changesListOpen} listOpen={changesListOpen}
onToggleList={toggleChangesList} onToggleList={toggleChangesList}
splitWidth={changesSplitWidth} splitWidth={changesSplitWidth}
@@ -765,8 +776,10 @@ const RightPanel: Component<RightPanelProps> = (props) => {
scopeKey={gitScopeKey} scopeKey={gitScopeKey}
diffViewMode={diffViewMode} diffViewMode={diffViewMode}
diffContextMode={diffContextMode} diffContextMode={diffContextMode}
diffWordWrapMode={diffWordWrapMode}
onViewModeChange={setDiffViewMode} onViewModeChange={setDiffViewMode}
onContextModeChange={setDiffContextMode} onContextModeChange={setDiffContextMode}
onWordWrapModeChange={setDiffWordWrapMode}
onOpenFile={(path) => void openGitFile(path)} onOpenFile={(path) => void openGitFile(path)}
onRefresh={() => void refreshGitStatus()} onRefresh={() => void refreshGitStatus()}
listOpen={gitChangesListOpen} listOpen={gitChangesListOpen}

View File

@@ -1,50 +1,61 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import type { DiffContextMode, DiffViewMode } from "../types" import { AlignJustify, FoldVertical, Split, UnfoldVertical, WrapText } from "lucide-solid"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface DiffToolbarProps { interface DiffToolbarProps {
viewMode: DiffViewMode viewMode: DiffViewMode
contextMode: DiffContextMode contextMode: DiffContextMode
wordWrapMode: DiffWordWrapMode
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
} }
const DiffToolbar: Component<DiffToolbarProps> = (props) => { const DiffToolbar: Component<DiffToolbarProps> = (props) => {
const nextViewMode = (): DiffViewMode => (props.viewMode === "split" ? "unified" : "split")
const nextContextMode = (): DiffContextMode => (props.contextMode === "collapsed" ? "expanded" : "collapsed")
const nextWordWrapMode = (): DiffWordWrapMode => (props.wordWrapMode === "on" ? "off" : "on")
const viewModeTitle = () => (nextViewMode() === "split" ? "Switch to split view" : "Switch to unified view")
const contextModeTitle = () =>
nextContextMode() === "collapsed" ? "Hide unchanged regions" : "Show full file"
const wordWrapTitle = () => (nextWordWrapMode() === "on" ? "Enable word wrap" : "Disable word wrap")
return ( return (
<div class="file-viewer-toolbar"> <div class="file-viewer-toolbar">
<button <button
type="button" type="button"
class={`file-viewer-toolbar-button${props.viewMode === "split" ? " active" : ""}`} class="file-viewer-toolbar-icon-button"
aria-pressed={props.viewMode === "split"} onClick={() => props.onViewModeChange(nextViewMode())}
onClick={() => props.onViewModeChange("split")} aria-label={viewModeTitle()}
title={viewModeTitle()}
> >
Split {nextViewMode() === "split" ? <Split class="h-4 w-4" aria-hidden="true" /> : <AlignJustify class="h-4 w-4" aria-hidden="true" />}
</button> </button>
<button <button
type="button" type="button"
class={`file-viewer-toolbar-button${props.viewMode === "unified" ? " active" : ""}`} class="file-viewer-toolbar-icon-button"
aria-pressed={props.viewMode === "unified"} onClick={() => props.onContextModeChange(nextContextMode())}
onClick={() => props.onViewModeChange("unified")} aria-label={contextModeTitle()}
title={contextModeTitle()}
> >
Unified {nextContextMode() === "collapsed" ? (
<FoldVertical class="h-4 w-4" aria-hidden="true" />
) : (
<UnfoldVertical class="h-4 w-4" aria-hidden="true" />
)}
</button> </button>
<button <button
type="button" type="button"
class={`file-viewer-toolbar-button${props.contextMode === "collapsed" ? " active" : ""}`} class={`file-viewer-toolbar-icon-button${props.wordWrapMode === "on" ? " active" : ""}`}
aria-pressed={props.contextMode === "collapsed"} onClick={() => props.onWordWrapModeChange(nextWordWrapMode())}
onClick={() => props.onContextModeChange("collapsed")} aria-label={wordWrapTitle()}
title="Hide unchanged regions" title={wordWrapTitle()}
> >
Collapsed <WrapText class="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
class={`file-viewer-toolbar-button${props.contextMode === "expanded" ? " active" : ""}`}
aria-pressed={props.contextMode === "expanded"}
onClick={() => props.onContextModeChange("expanded")}
title="Show full file"
>
Expanded
</button> </button>
</div> </div>
) )

View File

@@ -4,7 +4,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface ChangesTabProps { interface ChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -18,8 +18,10 @@ interface ChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
listOpen: Accessor<boolean> listOpen: Accessor<boolean>
onToggleList: () => void onToggleList: () => void
@@ -77,14 +79,6 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null} when={selectedFileData && hasSession && Array.isArray(diffs) && diffs.length > 0 ? selectedFileData : null}
@@ -102,6 +96,7 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
@@ -182,6 +177,17 @@ const ChangesTab: Component<ChangesTabProps> = (props) => {
<span class="files-tab-stat-value">-{totals.deletions}</span> <span class="files-tab-stat-value">-{totals.deletions}</span>
</span> </span>
</div> </div>
<div style={{ "margin-left": "auto" }}>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</div>
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -7,7 +7,7 @@ import { MonacoDiffViewer } from "../../../../file-viewer/monaco-diff-viewer"
import DiffToolbar from "../components/DiffToolbar" import DiffToolbar from "../components/DiffToolbar"
import SplitFilePanel from "../components/SplitFilePanel" import SplitFilePanel from "../components/SplitFilePanel"
import type { DiffContextMode, DiffViewMode } from "../types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode } from "../types"
interface GitChangesTabProps { interface GitChangesTabProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -29,8 +29,10 @@ interface GitChangesTabProps {
diffViewMode: Accessor<DiffViewMode> diffViewMode: Accessor<DiffViewMode>
diffContextMode: Accessor<DiffContextMode> diffContextMode: Accessor<DiffContextMode>
diffWordWrapMode: Accessor<DiffWordWrapMode>
onViewModeChange: (mode: DiffViewMode) => void onViewModeChange: (mode: DiffViewMode) => void
onContextModeChange: (mode: DiffContextMode) => void onContextModeChange: (mode: DiffContextMode) => void
onWordWrapModeChange: (mode: DiffWordWrapMode) => void
onOpenFile: (path: string) => void onOpenFile: (path: string) => void
onRefresh: () => void onRefresh: () => void
@@ -80,14 +82,6 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
const renderViewer = () => ( const renderViewer = () => (
<div class="file-viewer-panel flex-1"> <div class="file-viewer-panel flex-1">
<div class="file-viewer-header">
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
/>
</div>
<div class="file-viewer-content file-viewer-content--monaco"> <div class="file-viewer-content file-viewer-content--monaco">
<Show <Show
when={props.selectedLoading()} when={props.selectedLoading()}
@@ -122,6 +116,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
after={String((file() as any).after || "")} after={String((file() as any).after || "")}
viewMode={props.diffViewMode()} viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()} contextMode={props.diffContextMode()}
wordWrap={props.diffWordWrapMode()}
/> />
)} )}
</Show> </Show>
@@ -237,6 +232,15 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
> >
<RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} /> <RefreshCw class={`h-4 w-4${props.statusLoading() ? " animate-spin" : ""}`} />
</button> </button>
<DiffToolbar
viewMode={props.diffViewMode()}
contextMode={props.diffContextMode()}
wordWrapMode={props.diffWordWrapMode()}
onViewModeChange={props.onViewModeChange}
onContextModeChange={props.onContextModeChange}
onWordWrapModeChange={props.onWordWrapModeChange}
/>
</> </>
} }
list={{ panel: renderListPanel, overlay: renderListOverlay }} list={{ panel: renderListPanel, overlay: renderListOverlay }}

View File

@@ -1,8 +1,9 @@
import { For, Show, type Accessor, type Component } from "solid-js" import { For, Show, type Accessor, type Component } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { Tooltip } from "@kobalte/core/tooltip"
import { ChevronDown, TerminalSquare, Trash2, XOctagon } from "lucide-solid" import { ChevronDown, Info, TerminalSquare, Trash2, XOctagon } from "lucide-solid"
import type { Instance } from "../../../../../types/instance" import type { Instance } from "../../../../../types/instance"
import type { BackgroundProcess } from "../../../../../../../server/src/api-types" import type { BackgroundProcess } from "../../../../../../../server/src/api-types"
@@ -206,21 +207,25 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "session-changes", id: "session-changes",
labelKey: "instanceShell.rightPanel.sections.sessionChanges", labelKey: "instanceShell.rightPanel.sections.sessionChanges",
tooltipKey: "instanceShell.rightPanel.sections.sessionChanges.tooltip",
render: renderStatusSessionChanges, render: renderStatusSessionChanges,
}, },
{ {
id: "plan", id: "plan",
labelKey: "instanceShell.rightPanel.sections.plan", labelKey: "instanceShell.rightPanel.sections.plan",
tooltipKey: "instanceShell.rightPanel.sections.plan.tooltip",
render: renderPlanSectionContent, render: renderPlanSectionContent,
}, },
{ {
id: "background-processes", id: "background-processes",
labelKey: "instanceShell.rightPanel.sections.backgroundProcesses", labelKey: "instanceShell.rightPanel.sections.backgroundProcesses",
tooltipKey: "instanceShell.rightPanel.sections.backgroundProcesses.tooltip",
render: renderBackgroundProcesses, render: renderBackgroundProcesses,
}, },
{ {
id: "mcp", id: "mcp",
labelKey: "instanceShell.rightPanel.sections.mcp", labelKey: "instanceShell.rightPanel.sections.mcp",
tooltipKey: "instanceShell.rightPanel.sections.mcp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -233,6 +238,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "lsp", id: "lsp",
labelKey: "instanceShell.rightPanel.sections.lsp", labelKey: "instanceShell.rightPanel.sections.lsp",
tooltipKey: "instanceShell.rightPanel.sections.lsp.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -245,6 +251,7 @@ const StatusTab: Component<StatusTabProps> = (props) => {
{ {
id: "plugins", id: "plugins",
labelKey: "instanceShell.rightPanel.sections.plugins", labelKey: "instanceShell.rightPanel.sections.plugins",
tooltipKey: "instanceShell.rightPanel.sections.plugins.tooltip",
render: () => ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
initialInstance={props.instance} initialInstance={props.instance}
@@ -276,7 +283,23 @@ const StatusTab: Component<StatusTabProps> = (props) => {
<Accordion.Item value={section.id} class="right-panel-accordion-item"> <Accordion.Item value={section.id} class="right-panel-accordion-item">
<Accordion.Header> <Accordion.Header>
<Accordion.Trigger class="right-panel-accordion-trigger"> <Accordion.Trigger class="right-panel-accordion-trigger">
<span>{props.t(section.labelKey)}</span> <span class="section-left">
<Tooltip openDelay={200} gutter={4} placement="top">
<Tooltip.Trigger
class="section-info-trigger"
aria-label={props.t(section.tooltipKey)}
onClick={(e) => e.stopPropagation()}
>
<Info class="section-info-icon" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content class="section-info-tooltip">
{props.t(section.tooltipKey)}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip>
<span class="section-label">{props.t(section.labelKey)}</span>
</span>
<ChevronDown <ChevronDown
class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`} class={`right-panel-accordion-chevron ${isSectionExpanded(section.id) ? "right-panel-accordion-chevron-expanded" : ""}`}
/> />

View File

@@ -3,3 +3,5 @@ export type RightPanelTab = "changes" | "git-changes" | "files" | "status"
export type DiffViewMode = "split" | "unified" export type DiffViewMode = "split" | "unified"
export type DiffContextMode = "expanded" | "collapsed" export type DiffContextMode = "expanded" | "collapsed"
export type DiffWordWrapMode = "on" | "off"

View File

@@ -23,6 +23,7 @@ export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_NONPHONE_KEY = "opencode-session-
export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1" export const RIGHT_PANEL_GIT_CHANGES_LIST_OPEN_PHONE_KEY = "opencode-session-right-panel-git-changes-list-open-phone-v1"
export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_VIEW_MODE_KEY = "opencode-session-right-panel-changes-diff-view-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1" export const RIGHT_PANEL_CHANGES_DIFF_CONTEXT_MODE_KEY = "opencode-session-right-panel-changes-diff-context-mode-v1"
export const RIGHT_PANEL_CHANGES_DIFF_WORD_WRAP_KEY = "opencode-session-right-panel-changes-diff-word-wrap-v1"
export const clampWidth = (value: number) => export const clampWidth = (value: number) =>
Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value)) Math.min(MAX_SESSION_SIDEBAR_WIDTH, Math.max(MIN_SESSION_SIDEBAR_WIDTH, value))

View File

@@ -1,5 +1,5 @@
import { batch, createMemo, type Accessor } from "solid-js" import { batch, createMemo, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { Session } from "../../../types/session" import type { Session } from "../../../types/session"
import { import {
activeParentSessionId, activeParentSessionId,

View File

@@ -2,6 +2,7 @@ import { Index, type Accessor } from "solid-js"
import VirtualItem from "./virtual-item" import VirtualItem from "./virtual-item"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
export function getMessageAnchorId(messageId: string) { export function getMessageAnchorId(messageId: string) {
return `message-anchor-${messageId}` return `message-anchor-${messageId}`
@@ -21,8 +22,13 @@ interface MessageBlockListProps {
scrollContainer: Accessor<HTMLDivElement | undefined> scrollContainer: Accessor<HTMLDivElement | undefined>
loading?: boolean loading?: boolean
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
deleteHover?: Accessor<DeleteHoverState>
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: Accessor<Set<string>>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
setBottomSentinel: (element: HTMLDivElement | null) => void setBottomSentinel: (element: HTMLDivElement | null) => void
suspendMeasurements?: () => boolean suspendMeasurements?: () => boolean
} }
@@ -51,7 +57,12 @@ export default function MessageBlockList(props: MessageBlockListProps) {
showThinking={props.showThinking} showThinking={props.showThinking}
thinkingDefaultExpanded={props.thinkingDefaultExpanded} thinkingDefaultExpanded={props.thinkingDefaultExpanded}
showUsageMetrics={props.showUsageMetrics} showUsageMetrics={props.showUsageMetrics}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />

View File

@@ -1,5 +1,5 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, Trash2 } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call" import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
@@ -12,8 +12,17 @@ import { formatTokenTotal } from "../lib/formatters"
import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions" import { sessions, setActiveParentSession, setActiveSession } from "../stores/sessions"
import { setActiveInstanceId } from "../stores/instances" import { setActiveInstanceId } from "../stores/instances"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
const TOOL_ICON = "🔧" const TOOL_ICON = "🔧"
const USER_BORDER_COLOR = "var(--message-user-border)" const USER_BORDER_COLOR = "var(--message-user-border)"
@@ -23,10 +32,10 @@ const TOOL_BORDER_COLOR = "var(--message-tool-border)"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
type ToolState = import("@opencode-ai/sdk").ToolState type ToolState = import("@opencode-ai/sdk/v2").ToolState
type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
type ToolStateError = import("@opencode-ai/sdk").ToolStateError type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning { function isToolStateRunning(state: ToolState | undefined): state is ToolStateRunning {
return Boolean(state && state.status === "running") return Boolean(state && state.status === "running")
@@ -194,8 +203,13 @@ interface MessageContentItemProps {
messageIndex: number messageIndex: number
lastAssistantIndex: () => number lastAssistantIndex: () => number
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function isSupportedPartType(part: unknown): boolean { function isSupportedPartType(part: unknown): boolean {
@@ -282,7 +296,12 @@ function MessageContentItem(props: MessageContentItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
isQueued={isQueued()} isQueued={isQueued()}
showAgentMeta={showAgentMeta()} showAgentMeta={showAgentMeta()}
showDeleteMessage={props.showDeleteMessage}
onDeleteHoverChange={props.onDeleteHoverChange}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -298,11 +317,19 @@ interface ToolCallItemProps {
messageId: string messageId: string
partId: string partId: string
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ToolCallItem(props: ToolCallItemProps) { function ToolCallItem(props: ToolCallItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const record = createMemo(() => props.store().getMessage(props.messageId)) const record = createMemo(() => props.store().getMessage(props.messageId))
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
@@ -319,14 +346,6 @@ function ToolCallItem(props: ToolCallItemProps) {
const messageVersion = createMemo(() => record()?.revision ?? 0) const messageVersion = createMemo(() => record()?.revision ?? 0)
const partVersion = createMemo(() => partEntry()?.revision ?? 0) const partVersion = createMemo(() => partEntry()?.revision ?? 0)
const deleteDisabled = createMemo(() => {
if (deleting()) return true
// Avoid deleting while a tool is actively running to prevent confusing UI states.
if (isToolStateRunning(toolState())) return true
// Avoid deleting permission prompts from here; those are interactive.
return Boolean(toolPart()?.pendingPermission)
})
const taskSessionId = createMemo(() => { const taskSessionId = createMemo(() => {
const state = toolState() const state = toolState()
if (!state) return "" if (!state) return ""
@@ -350,38 +369,72 @@ function ToolCallItem(props: ToolCallItemProps) {
navigateToTaskSession(location) navigateToTaskSession(location)
} }
const handleDeleteToolPart = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (deleteDisabled()) return if (!props.showDeleteMessage) return
if (deletingMessage()) return
setDeleting(true) setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messageBlock.tool.deletePart.failed.message"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageBlock.tool.deletePart.failed.title"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<Show when={toolPart()}> <Show when={toolPart()}>
{(resolvedToolPart) => ( {(resolvedToolPart) => (
<> <div class="delete-hover-scope">
<div class="tool-call-header-label"> <div class="tool-call-header-label">
<div class="tool-call-header-meta"> <div class="tool-call-header-meta">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="tool-call-icon">{TOOL_ICON}</span> <span class="tool-call-icon">{TOOL_ICON}</span>
<span>{t("messageBlock.tool.header")}</span> <span>{t("messageBlock.tool.header")}</span>
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span> <span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-0">
<Show when={taskSessionId()}> <Show when={taskSessionId()}>
<button <button
class="tool-call-header-button" class="tool-call-header-button"
@@ -395,16 +448,33 @@ function ToolCallItem(props: ToolCallItemProps) {
</button> </button>
</Show> </Show>
<button <Show when={props.showDeleteMessage}>
class="tool-call-header-button" <button
type="button" class="tool-call-header-button"
disabled={deleteDisabled()} type="button"
onClick={handleDeleteToolPart} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
title={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} onClick={handleDeleteUpTo}
aria-label={deleting() ? t("messageBlock.tool.deletePart.deleting") : t("messageBlock.tool.deletePart.label")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
> onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" /> title={t("messageItem.actions.deleteMessagesUpTo")}
</button> aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="tool-call-header-button"
type="button"
disabled={deletingMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</div> </div>
@@ -418,7 +488,7 @@ function ToolCallItem(props: ToolCallItemProps) {
sessionId={props.sessionId} sessionId={props.sessionId}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</> </div>
)} )}
</Show> </Show>
) )
@@ -470,7 +540,12 @@ interface MessageBlockProps {
showThinking: () => boolean showThinking: () => boolean
thinkingDefaultExpanded: () => boolean thinkingDefaultExpanded: () => boolean
showUsageMetrics: () => boolean showUsageMetrics: () => boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
onContentRendered?: () => void onContentRendered?: () => void
} }
@@ -481,6 +556,30 @@ export default function MessageBlock(props: MessageBlockProps) {
const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId)) const messageInfo = createMemo(() => props.store().getMessageInfo(props.messageId))
const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId) const sessionCache = getSessionRenderCache(props.instanceId, props.sessionId)
const isDeleteMessageHovered = () => {
const hover = props.deleteHover?.() ?? ({ kind: "none" } as DeleteHoverState)
const selected = props.selectedMessageIds?.() ?? new Set<string>()
if (selected.has(props.messageId)) {
return true
}
if (hover.kind === "message") {
return hover.messageId === props.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = props.store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const currentIndex = ids.indexOf(props.messageId)
if (currentIndex === -1) return false
return currentIndex >= targetIndex
}
return false
}
const block = createMemo<MessageDisplayBlock | null>(() => { const block = createMemo<MessageDisplayBlock | null>(() => {
const current = record() const current = record()
if (!current) return null if (!current) return null
@@ -668,9 +767,13 @@ export default function MessageBlock(props: MessageBlockProps) {
return ( return (
<Show when={block()}> <Show when={block()}>
{(resolvedBlock) => ( {(resolvedBlock) => (
<div class="message-stream-block" data-message-id={resolvedBlock().record.id}> <div
class="message-stream-block"
data-message-id={resolvedBlock().record.id}
data-delete-message-hover={isDeleteMessageHovered() ? "true" : undefined}
>
<For each={resolvedBlock().items}> <For each={resolvedBlock().items}>
{(item) => ( {(item, index) => (
<Switch> <Switch>
<Match when={item.type === "content"}> <Match when={item.type === "content"}>
<MessageContentItem <MessageContentItem
@@ -681,7 +784,12 @@ export default function MessageBlock(props: MessageBlockProps) {
startPartId={(item as ContentDisplayItem).startPartId} startPartId={(item as ContentDisplayItem).startPartId}
messageIndex={props.messageIndex} messageIndex={props.messageIndex}
lastAssistantIndex={props.lastAssistantIndex} lastAssistantIndex={props.lastAssistantIndex}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
@@ -697,6 +805,11 @@ export default function MessageBlock(props: MessageBlockProps) {
store={props.store} store={props.store}
messageId={toolItem.messageId} messageId={toolItem.messageId}
partId={toolItem.partId} partId={toolItem.partId}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered} onContentRendered={props.onContentRendered}
/> />
</div> </div>
@@ -709,6 +822,14 @@ export default function MessageBlock(props: MessageBlockProps) {
part={(item as StepDisplayItem).part} part={(item as StepDisplayItem).part}
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showAgentMeta showAgentMeta
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "step-finish"}> <Match when={item.type === "step-finish"}>
@@ -718,6 +839,14 @@ export default function MessageBlock(props: MessageBlockProps) {
messageInfo={(item as StepDisplayItem).messageInfo} messageInfo={(item as StepDisplayItem).messageInfo}
showUsage={props.showUsageMetrics()} showUsage={props.showUsageMetrics()}
borderColor={(item as StepDisplayItem).accentColor} borderColor={(item as StepDisplayItem).accentColor}
showDeleteMessage={index() === 0}
instanceId={props.instanceId}
sessionId={props.sessionId}
messageId={props.messageId}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "compaction"}> <Match when={item.type === "compaction"}>
@@ -728,7 +857,11 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as CompactionDisplayItem).messageId} messageId={(item as CompactionDisplayItem).messageId}
partId={(item as CompactionDisplayItem).partId} showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
<Match when={item.type === "reasoning"}> <Match when={item.type === "reasoning"}>
@@ -738,9 +871,13 @@ export default function MessageBlock(props: MessageBlockProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
messageId={(item as ReasoningDisplayItem).messageId} messageId={(item as ReasoningDisplayItem).messageId}
partId={(item as ReasoningDisplayItem).partId}
showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta} showAgentMeta={(item as ReasoningDisplayItem).showAgentMeta}
defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded} defaultExpanded={(item as ReasoningDisplayItem).defaultExpanded}
showDeleteMessage={index() === 0}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -759,6 +896,14 @@ interface StepCardProps {
showAgentMeta?: boolean showAgentMeta?: boolean
showUsage?: boolean showUsage?: boolean
borderColor?: string borderColor?: string
showDeleteMessage?: boolean
instanceId?: string
sessionId?: string
messageId?: string
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
interface CompactionCardProps { interface CompactionCardProps {
@@ -768,12 +913,18 @@ interface CompactionCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function CompactionCard(props: CompactionCardProps) { function CompactionCard(props: CompactionCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
const isAuto = () => Boolean((props.part as any)?.auto) const isAuto = () => Boolean((props.part as any)?.auto)
const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel")) const label = () => (isAuto() ? t("messageBlock.compaction.autoLabel") : t("messageBlock.compaction.manualLabel"))
const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR) const borderColor = () => props.borderColor ?? (isAuto() ? "var(--session-status-compacting-fg)" : USER_BORDER_COLOR)
@@ -781,44 +932,98 @@ function CompactionCard(props: CompactionCardProps) {
const containerClass = () => const containerClass = () =>
`message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}` `message-compaction-card ${isAuto() ? "message-compaction-card--auto" : "message-compaction-card--manual"}`
const canDelete = () => Boolean(props.partId) && !deleting() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDelete = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canDelete()) return if (!props.showDeleteMessage) return
setDeleting(true) if (!canDeleteMessage()) return
setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div <div
class={`${containerClass()} relative`} class={`delete-hover-scope ${containerClass()} relative`}
style={{ "border-left": `4px solid ${borderColor()}` }} style={{ "border-left": `4px solid ${borderColor()}` }}
role="status" role="status"
aria-label={t("messageBlock.compaction.ariaLabel")} aria-label={t("messageBlock.compaction.ariaLabel")}
> >
<button <div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
type="button" <Show when={props.showDeleteMessage}>
class="tool-call-header-button absolute right-2 top-1/2 -translate-y-1/2" <button
disabled={!canDelete()} type="button"
onClick={handleDelete} class="tool-call-header-button"
title={t("messagePart.actions.deleteTitle")} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
> onClick={handleDeleteUpTo}
{deleting() ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
</button> onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="tool-call-header-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div>
<div class="message-compaction-row"> <div class="message-compaction-row">
<Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" /> <FoldVertical class="message-compaction-icon w-4 h-4" aria-hidden="true" />
<span class="message-compaction-label">{label()}</span> <span class="message-compaction-label">{label()}</span>
</div> </div>
@@ -828,6 +1033,9 @@ function CompactionCard(props: CompactionCardProps) {
function StepCard(props: StepCardProps) { function StepCard(props: StepCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.messageId && props.selectedMessageIds?.().has(props.messageId))
const timestamp = () => { const timestamp = () => {
const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now() const value = props.messageInfo?.time?.created ?? (props.part as any)?.time?.start ?? Date.now()
const date = new Date(value) const date = new Date(value)
@@ -872,6 +1080,42 @@ function StepCard(props: StepCardProps) {
const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined) const finishStyle = () => (props.borderColor ? { "border-left-color": props.borderColor } : undefined)
const canDeleteMessage = () =>
Boolean(props.showDeleteMessage && props.instanceId && props.sessionId && props.messageId) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!canDeleteMessage()) return
setDeletingMessage(true)
try {
await deleteMessage(props.instanceId!, props.sessionId!, props.messageId!)
} catch (error) {
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
} finally {
setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.messageId) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
}
}
const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => { const renderUsageChips = (usage: NonNullable<ReturnType<typeof usageStats>>) => {
const entries = [ const entries = [
@@ -902,17 +1146,83 @@ function StepCard(props: StepCardProps) {
return null return null
} }
return ( return (
<div class={`message-step-card message-step-finish message-step-finish-flush`} style={finishStyle()}> <div class={`message-step-card message-step-finish message-step-finish-flush relative`} style={finishStyle()}>
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox absolute left-2 top-1/2 -translate-y-1/2"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showDeleteMessage}>
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<button
type="button"
class="message-action-button"
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onClick={handleDeleteUpTo}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
disabled={!canDeleteMessage()}
onClick={handleDeleteMessage}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId! })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</div>
</Show>
{renderUsageChips(usage)} {renderUsageChips(usage)}
</div> </div>
) )
} }
return ( return (
<div class={`message-step-card message-step-start`}> <div class={`message-step-card message-step-start relative`}>
<div class="message-step-heading"> <div class="message-step-heading">
<div class="message-step-title"> <div class="message-step-title">
<div class="message-step-title-left"> <div class="message-step-title-left">
<Show when={props.showDeleteMessage && props.messageId}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId!, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show> <Show when={agentIdentifier()}>{(value) => <span>{t("messageBlock.step.agentLabel", { agent: value() })}</span>}</Show>
@@ -939,15 +1249,27 @@ interface ReasoningCardProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
partId: string
showAgentMeta?: boolean showAgentMeta?: boolean
defaultExpanded?: boolean defaultExpanded?: boolean
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
const { t } = useI18n() const { t } = useI18n()
const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded)) const [expanded, setExpanded] = createSignal(Boolean(props.defaultExpanded))
const [deleting, setDeleting] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let headerEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let primaryEl: HTMLSpanElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -974,6 +1296,35 @@ function ReasoningCard(props: ReasoningCardProps) {
return modelID return modelID
} }
const hasMeta = () => Boolean(props.showAgentMeta && (agentIdentifier() || modelIdentifier()))
const updateMetaLayout = () => {
if (!hasMeta()) return
if (!headerEl || !actionsEl || !primaryEl || !metaMeasureEl) return
const headerWidth = headerEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = primaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
const availableLeft = Math.max(0, headerWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
if (!hasMeta() || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (headerEl) observer.observe(headerEl)
if (actionsEl) observer.observe(actionsEl)
if (primaryEl) observer.observe(primaryEl)
onCleanup(() => observer.disconnect())
})
const reasoningText = () => { const reasoningText = () => {
const part = props.part as any const part = props.part as any
if (!part) return "" if (!part) return ""
@@ -1014,30 +1365,45 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
const hasDeleteTarget = () => Boolean(props.partId) const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const canDelete = () => hasDeleteTarget() && !deleting()
const handleDelete = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!canDelete()) return if (!props.showDeleteMessage) return
setDeleting(true) if (!canDeleteMessage()) return
setDeletingMessage(true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.messageId, props.partId) await deleteMessage(props.instanceId, props.sessionId, props.messageId)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setDeleting(false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
if (!props.showDeleteMessage) return
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.messageId)
} finally {
setDeletingUpTo(false)
} }
} }
return ( return (
<div class="message-reasoning-card"> <div class="delete-hover-scope message-reasoning-card">
<div class="message-reasoning-header"> <div class="message-reasoning-header" ref={(el) => (headerEl = el)}>
<button <button
type="button" type="button"
class="message-reasoning-toggle" class="message-reasoning-toggle"
@@ -1045,9 +1411,30 @@ function ReasoningCard(props: ReasoningCardProps) {
aria-expanded={expanded()} aria-expanded={expanded()}
aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")} aria-label={expanded() ? t("messageBlock.reasoning.collapseAriaLabel") : t("messageBlock.reasoning.expandAriaLabel")}
> >
<span class="message-reasoning-label flex flex-wrap items-center gap-2"> <span class="message-reasoning-label">
<span>{t("messageBlock.reasoning.thinkingLabel")}</span> <span class="message-reasoning-label-primary" ref={(el) => (primaryEl = el)}>
<Show when={props.showAgentMeta && (agentIdentifier() || modelIdentifier())}> <Show when={props.showDeleteMessage}>
<input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.messageId, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span>{t("messageBlock.reasoning.thinkingLabel")}</span>
</span>
<Show when={hasMeta() && showMetaInline()}>
<span class="message-step-meta-inline"> <span class="message-step-meta-inline">
<Show when={agentIdentifier()}> <Show when={agentIdentifier()}>
{(value) => ( {(value) => (
@@ -1061,10 +1448,28 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</span> </span>
</Show> </Show>
<Show when={hasMeta()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-step-meta-inline message-step-meta-inline--measure"
>
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</Show>
</span> </span>
</button> </button>
<div class="message-reasoning-actions"> <div class="message-reasoning-actions" ref={(el) => (actionsEl = el)}>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
@@ -1081,16 +1486,31 @@ function ReasoningCard(props: ReasoningCardProps) {
</Show> </Show>
</button> </button>
<Show when={hasDeleteTarget()}> <Show when={props.showDeleteMessage}>
<button <button
type="button" type="button"
class="message-action-button" class="message-action-button"
onClick={handleDelete} onClick={handleDeleteUpTo}
disabled={!canDelete()} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
aria-label={t("messagePart.actions.deleteTitle")} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId })}
title={t("messagePart.actions.deleteTitle")} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
title={t("messageItem.actions.deleteMessagesUpTo")}
> >
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" /> <DeleteUpToIcon />
</button>
<button
type="button"
class="message-action-button"
onClick={handleDeleteMessage}
disabled={!canDeleteMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
@@ -1098,6 +1518,23 @@ function ReasoningCard(props: ReasoningCardProps) {
</div> </div>
</div> </div>
<Show when={hasMeta() && !showMetaInline()}>
<div class="message-reasoning-meta-row">
<span class="message-step-meta-inline">
<Show when={agentIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.agentLabel", { agent: value() })}</span>
)}
</Show>
<Show when={modelIdentifier()}>
{(value) => (
<span class="font-medium text-[var(--message-assistant-border)]">{t("messageBlock.step.modelLabel", { model: value() })}</span>
)}
</Show>
</span>
</div>
</Show>
<Show when={expanded()}> <Show when={expanded()}>
<div class="message-reasoning-expanded"> <div class="message-reasoning-expanded">
<div class="message-reasoning-body"> <div class="message-reasoning-body">

View File

@@ -1,14 +1,24 @@
import { For, Show, createSignal } from "solid-js" import { For, Show, createEffect, createSignal, onCleanup } from "solid-js"
import { Copy, ExternalLink, Split, Trash2, Undo } from "lucide-solid" import { Portal } from "solid-js/web"
import type { MessageInfo, ClientPart } from "../types/message" import { Copy, ListStart, Split, Trash, Undo } from "lucide-solid"
import type { MessageInfo, ClientPart, SDKAssistantMessageV2 } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
import type { MessageRecord } from "../stores/message-v2/types" import type { MessageRecord } from "../stores/message-v2/types"
import MessagePart from "./message-part" import MessagePart from "./message-part"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { showAlertDialog } from "../stores/alerts" import { showAlertDialog } from "../stores/alerts"
import { deleteMessagePart } from "../stores/session-actions" import { deleteMessage } from "../stores/session-actions"
import { isTauriHost } from "../lib/runtime-env" import { isTauriHost } from "../lib/runtime-env"
import type { DeleteHoverState } from "../types/delete-hover"
function DeleteUpToIcon() {
return (
<span class="relative inline-block w-3.5 h-3.5" aria-hidden="true">
<ListStart class="absolute inset-0 w-3.5 h-3.5" aria-hidden="true" />
</span>
)
}
interface MessageItemProps { interface MessageItemProps {
record: MessageRecord record: MessageRecord
@@ -18,15 +28,112 @@ interface MessageItemProps {
isQueued?: boolean isQueued?: boolean
parts: ClientPart[] parts: ClientPart[]
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
showAgentMeta?: boolean showAgentMeta?: boolean
onContentRendered?: () => void onContentRendered?: () => void
showDeleteMessage?: boolean
onDeleteHoverChange?: (state: DeleteHoverState) => void
} }
export default function MessageItem(props: MessageItemProps) { export default function MessageItem(props: MessageItemProps) {
const { t } = useI18n() const { t } = useI18n()
const [copied, setCopied] = createSignal(false) const [copied, setCopied] = createSignal(false)
const [deletingParts, setDeletingParts] = createSignal<Set<string>>(new Set()) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false)
type ImagePreviewState = {
url: string
name: string
anchor: HTMLElement
}
const [imagePreview, setImagePreview] = createSignal<ImagePreviewState | null>(null)
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))
const getImagePreviewPosition = () => {
const state = imagePreview()
if (!state) return null
const rect = state.anchor.getBoundingClientRect()
// Outer box: 320px image + 8px padding on each side.
const padding = 8
const maxImage = 320
const gap = 8
const chrome = padding * 2
const outerWidth = maxImage + chrome
const outerHeight = maxImage + chrome
const viewportW = window.innerWidth
const viewportH = window.innerHeight
const left = clamp(rect.left, 8, Math.max(8, viewportW - outerWidth - 8))
const fitsAbove = rect.top >= outerHeight + gap + 8
const preferredTop = fitsAbove ? rect.top - outerHeight - gap : rect.bottom + gap
const top = clamp(preferredTop, 8, Math.max(8, viewportH - outerHeight - 8))
return { left, top }
}
createEffect(() => {
const active = imagePreview()
if (!active) return
// If the user scrolls (message stream scroll container) or resizes, the anchor moves.
// Hide the popover to avoid showing it in the wrong place.
const hide = () => setImagePreview(null)
window.addEventListener("scroll", hide, true)
window.addEventListener("resize", hide)
onCleanup(() => {
window.removeEventListener("scroll", hide, true)
window.removeEventListener("resize", hide)
})
})
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.record.id))
let topRowEl: HTMLDivElement | undefined
let actionsEl: HTMLDivElement | undefined
let speakerPrimaryEl: HTMLDivElement | undefined
let metaMeasureEl: HTMLSpanElement | undefined
const [showMetaInline, setShowMetaInline] = createSignal(true)
const metaText = () => agentMeta()
const updateMetaLayout = () => {
const text = metaText()
if (!text) return
if (!topRowEl || !actionsEl || !speakerPrimaryEl || !metaMeasureEl) return
const rowWidth = topRowEl.getBoundingClientRect().width
const actionsWidth = actionsEl.getBoundingClientRect().width
const primaryWidth = speakerPrimaryEl.getBoundingClientRect().width
const metaWidth = metaMeasureEl.getBoundingClientRect().width
// Allow for the flex gap between left and actions.
const availableLeft = Math.max(0, rowWidth - actionsWidth - 12)
setShowMetaInline(primaryWidth + metaWidth + 8 <= availableLeft)
}
createEffect(() => {
const text = metaText()
if (!text || typeof ResizeObserver === "undefined") {
setShowMetaInline(true)
return
}
updateMetaLayout()
const observer = new ResizeObserver(() => updateMetaLayout())
if (topRowEl) observer.observe(topRowEl)
if (actionsEl) observer.observe(actionsEl)
if (speakerPrimaryEl) observer.observe(speakerPrimaryEl)
onCleanup(() => observer.disconnect())
})
const isUser = () => props.record.role === "user" const isUser = () => props.record.role === "user"
const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt const createdTimestamp = () => props.messageInfo?.time?.created ?? props.record.createdAt
@@ -123,6 +230,11 @@ export default function MessageItem(props: MessageItemProps) {
} }
} }
const showImagePreview = (anchor: HTMLElement, url: string, name: string) => {
if (!url) return
setImagePreview({ anchor, url, name })
}
const errorMessage = () => { const errorMessage = () => {
const info = props.messageInfo const info = props.messageInfo
if (!info || info.role !== "assistant" || !info.error) return null if (!info || info.role !== "assistant" || !info.error) return null
@@ -190,47 +302,30 @@ export default function MessageItem(props: MessageItemProps) {
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} }
const deletableTextPartId = () => { const handleDeleteMessage = async () => {
const part = props.parts.find((candidate) => { if (deletingMessage()) return
if (!candidate || candidate.type !== "text") return false setDeletingMessage(true)
const id = (candidate as any).id
if (typeof id !== "string" || id.length === 0) return false
return !Boolean((candidate as any).synthetic)
})
return (part as any)?.id as string | undefined
}
const isDeletingPart = (partId?: string) => {
if (!partId) return false
return deletingParts().has(partId)
}
const setPartDeleting = (partId: string, value: boolean) => {
setDeletingParts((prev) => {
const next = new Set(prev)
if (value) {
next.add(partId)
} else {
next.delete(partId)
}
return next
})
}
const handleDeletePart = async (partId?: string) => {
if (!partId) return
if (isDeletingPart(partId)) return
setPartDeleting(partId, true)
try { try {
await deleteMessagePart(props.instanceId, props.sessionId, props.record.id, partId) await deleteMessage(props.instanceId, props.sessionId, props.record.id)
} catch (error) { } catch (error) {
showAlertDialog(t("messagePart.actions.deleteFailedMessage"), { showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
title: t("messagePart.actions.deleteFailedTitle"), title: t("messageItem.actions.deleteMessageFailedTitle"),
detail: error instanceof Error ? error.message : String(error), detail: error instanceof Error ? error.message : String(error),
variant: "error", variant: "error",
}) })
} finally { } finally {
setPartDeleting(partId, false) setDeletingMessage(false)
}
}
const handleDeleteUpTo = async () => {
if (!props.onDeleteMessagesUpTo) return
if (deletingUpTo()) return
setDeletingUpTo(true)
try {
await props.onDeleteMessagesUpTo(props.record.id)
} finally {
setDeletingUpTo(false)
} }
} }
@@ -258,8 +353,16 @@ export default function MessageItem(props: MessageItemProps) {
if (!info || info.role !== "assistant") return "" if (!info || info.role !== "assistant") return ""
const modelID = info.modelID || "" const modelID = info.modelID || ""
const providerID = info.providerID || "" const providerID = info.providerID || ""
if (modelID && providerID) return `${providerID}/${modelID}`
return modelID const base = modelID && providerID ? `${providerID}/${modelID}` : modelID
if (!base) return ""
const variant = (info as SDKAssistantMessageV2).variant
if (typeof variant === "string" && variant.trim().length > 0) {
return `${base} (${variant.trim()})`
}
return base
} }
const agentMeta = () => { const agentMeta = () => {
@@ -280,26 +383,58 @@ export default function MessageItem(props: MessageItemProps) {
return ( return (
<div class={containerClass()}> <div class={containerClass()}>
<header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}> <header class={`message-item-header ${isUser() ? "pb-0.5" : "pb-0"}`}>
<div class="message-item-header-row message-item-header-row--top"> <div class="message-item-header-row message-item-header-row--top" ref={(el) => (topRowEl = el)}>
<div class="message-speaker"> <div class="message-header-left">
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}> <div class="message-speaker-primary" ref={(el) => (speakerPrimaryEl = el)}>
{speakerLabel()} <Show when={props.showDeleteMessage}>
</span> <input
class="message-select-checkbox"
type="checkbox"
checked={isSelectedForDeletion()}
onClick={(event) => {
event.stopPropagation()
}}
onChange={(event) => {
event.stopPropagation()
const next = Boolean((event.currentTarget as HTMLInputElement).checked)
props.onToggleSelectedMessage?.(props.record.id, next)
}}
aria-label={t("messageItem.selection.checkboxAriaLabel")}
title={t("messageItem.selection.checkboxAriaLabel")}
/>
</Show>
<span class="message-speaker-label" data-role={isUser() ? "user" : "assistant"}>
{speakerLabel()}
</span>
</div>
<Show when={metaText() && showMetaInline()}>
<span class="message-agent-meta-inline">{metaText()}</span>
</Show>
<Show when={metaText()}>
<span
ref={(el) => (metaMeasureEl = el)}
class="message-agent-meta-inline message-agent-meta-inline--measure"
>
{metaText()}
</span>
</Show>
</div> </div>
<div class="message-item-actions"> <div class="message-item-actions" ref={(el) => (actionsEl = el)}>
<Show when={isUser()}> <Show when={isUser()}>
<div class="message-action-group"> <div class="message-action-group">
<Show when={props.onRevert}> <button
<button class="message-action-button"
class="message-action-button" onClick={handleCopy}
onClick={handleRevert} title={copyLabel()}
title={t("messageItem.actions.revert")} aria-label={copyLabel()}
aria-label={t("messageItem.actions.revert")} >
> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
<Undo class="w-3.5 h-3.5" aria-hidden="true" /> </button>
</button>
</Show>
<Show when={props.onFork}> <Show when={props.onFork}>
<button <button
class="message-action-button" class="message-action-button"
@@ -310,14 +445,43 @@ export default function MessageItem(props: MessageItemProps) {
<Split class="w-3.5 h-3.5" aria-hidden="true" /> <Split class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</Show> </Show>
<button
class="message-action-button" <Show when={props.onRevert}>
onClick={handleCopy} <button
title={copyLabel()} class="message-action-button"
aria-label={copyLabel()} onClick={handleRevert}
> title={t("messageItem.actions.revertTitle")}
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> aria-label={t("messageItem.actions.revertTitle")}
</button> >
<Undo class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
<Show when={props.showDeleteMessage}>
<button
class="message-action-button"
onClick={() => void handleDeleteUpTo()}
disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={t("messageItem.actions.deleteMessagesUpTo")}
aria-label={t("messageItem.actions.deleteMessagesUpTo")}
>
<DeleteUpToIcon />
</button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show>
</div> </div>
</Show> </Show>
<Show when={!isUser()}> <Show when={!isUser()}>
@@ -331,18 +495,30 @@ export default function MessageItem(props: MessageItemProps) {
<Copy class="w-3.5 h-3.5" aria-hidden="true" /> <Copy class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
<Show when={deletableTextPartId()}> <Show when={props.showDeleteMessage}>
{(partId) => ( <button
<button class="message-action-button"
class="message-action-button" onClick={() => void handleDeleteUpTo()}
onClick={() => void handleDeletePart(partId())} disabled={!props.onDeleteMessagesUpTo || deletingUpTo()}
disabled={isDeletingPart(partId())} onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.record.id })}
title={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
aria-label={isDeletingPart(partId()) ? t("messagePart.actions.deleting") : t("messagePart.actions.delete")} title={t("messageItem.actions.deleteMessagesUpTo")}
> aria-label={t("messageItem.actions.deleteMessagesUpTo")}
<Trash2 class="w-3.5 h-3.5" aria-hidden="true" /> >
</button> <DeleteUpToIcon />
)} </button>
<button
class="message-action-button"
onClick={handleDeleteMessage}
disabled={deletingMessage()}
onMouseEnter={() => props.onDeleteHoverChange?.({ kind: "message", messageId: props.record.id })}
onMouseLeave={() => props.onDeleteHoverChange?.({ kind: "none" })}
title={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
aria-label={deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage")}
>
<Trash class="w-3.5 h-3.5" aria-hidden="true" />
</button>
</Show> </Show>
</div> </div>
</Show> </Show>
@@ -350,12 +526,10 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</div> </div>
<Show when={agentMeta()}> <Show when={metaText() && !showMetaInline()}>
{(meta) => ( <div class="message-item-header-row message-item-header-row--meta">
<div class="message-item-header-row message-item-header-row--bottom"> <span class="message-agent-meta-block">{metaText()}</span>
<span class="message-agent-meta">{meta()}</span> </div>
</div>
)}
</Show> </Show>
</header> </header>
@@ -378,16 +552,20 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<For each={messageParts()}> <For each={messageParts()}>
{(part) => ( {(part) => {
<MessagePart return (
part={part} <div class="message-part-shell">
messageType={props.record.role} <MessagePart
instanceId={props.instanceId} part={part}
sessionId={props.sessionId} messageType={props.record.role}
primaryUserTextPartId={primaryUserTextPartId()} instanceId={props.instanceId}
onRendered={props.onContentRendered} sessionId={props.sessionId}
/> primaryUserTextPartId={primaryUserTextPartId()}
)} onRendered={props.onContentRendered}
/>
</div>
)
}}
</For> </For>
<Show when={fileAttachments().length > 0}> <Show when={fileAttachments().length > 0}>
@@ -397,7 +575,16 @@ export default function MessageItem(props: MessageItemProps) {
const name = getAttachmentName(attachment) const name = getAttachmentName(attachment)
const isImage = isImageAttachment(attachment) const isImage = isImageAttachment(attachment)
return ( return (
<div class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`} title={name}> <div
class={`attachment-chip ${isImage ? "attachment-chip-image" : ""}`}
title={name}
onMouseEnter={(e) => {
if (!isImage) return
const el = e.currentTarget as HTMLElement
showImagePreview(el, attachment.url || "", name)
}}
onMouseLeave={() => setImagePreview(null)}
>
<Show when={isImage} fallback={ <Show when={isImage} fallback={
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path <path
@@ -425,24 +612,6 @@ export default function MessageItem(props: MessageItemProps) {
</svg> </svg>
</button> </button>
</Show> </Show>
<button
type="button"
onClick={() => void handleDeletePart(attachment.id)}
class="attachment-remove"
disabled={isDeletingPart(attachment.id)}
aria-label={t("messagePart.actions.deleteTitle")}
title={t("messagePart.actions.deleteTitle")}
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<Show when={isImage}>
<div class="attachment-chip-preview">
<img src={attachment.url} alt={name} />
</div>
</Show>
</div> </div>
) )
}} }}
@@ -450,6 +619,31 @@ export default function MessageItem(props: MessageItemProps) {
</div> </div>
</Show> </Show>
<Show when={imagePreview()}>
{(stateAccessor) => {
const state = stateAccessor()
const pos = () => getImagePreviewPosition()
return (
<Portal>
<Show when={pos()}>
{(posAccessor) => {
const coords = posAccessor()
return (
<div
class="attachment-image-popover"
style={{ left: `${coords.left}px`, top: `${coords.top}px` }}
aria-hidden="true"
>
<img src={state.url} alt={state.name} />
</div>
)
}}
</Show>
</Portal>
)
}}
</Show>
<Show when={props.record.status === "sending"}> <Show when={props.record.status === "sending"}>
<div class="message-sending"> <div class="message-sending">
<span class="generating-spinner"></span> {t("messageItem.status.sending")} <span class="generating-spinner"></span> {t("messageItem.status.sending")}

View File

@@ -1,10 +1,8 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import Kbd from "./kbd" import Kbd from "./kbd"
import ContextMeter from "./context-meter"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
const METRIC_CHIP_CLASS = "inline-flex items-center gap-1 rounded-full border border-base px-2 py-0.5 text-xs text-primary"
const METRIC_LABEL_CLASS = "uppercase text-[10px] tracking-wide text-muted"
interface MessageListHeaderProps { interface MessageListHeaderProps {
usedTokens: number usedTokens: number
@@ -21,7 +19,6 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
const { t } = useI18n() const { t } = useI18n()
const hasAvailableTokens = () => typeof props.availableTokens === "number" const hasAvailableTokens = () => typeof props.availableTokens === "number"
const availableDisplay = () => (hasAvailableTokens() ? props.formatTokens(props.availableTokens as number) : "--")
return ( return (
<div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}> <div class={props.forceCompactStatusLayout ? "connection-status connection-status--compact" : "connection-status"}>
@@ -40,14 +37,13 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-text connection-status-info"> <div class="connection-status-text connection-status-info">
<div class="connection-status-usage"> <div class="connection-status-usage">
<div class={METRIC_CHIP_CLASS}> <ContextMeter
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.usedLabel")}</span> usedTokens={props.usedTokens}
<span class="font-semibold text-primary">{props.formatTokens(props.usedTokens)}</span> availableTokens={hasAvailableTokens() ? (props.availableTokens as number) : null}
</div> formatTokens={props.formatTokens}
<div class={METRIC_CHIP_CLASS}> usedLabel={t("messageListHeader.metrics.usedLabel")}
<span class={METRIC_LABEL_CLASS}>{t("messageListHeader.metrics.availableLabel")}</span> availableLabel={t("messageListHeader.metrics.availableLabel")}
<span class="font-semibold text-primary">{hasAvailableTokens() ? availableDisplay() : "--"}</span> />
</div>
</div> </div>
</div> </div>
@@ -55,7 +51,7 @@ export default function MessageListHeader(props: MessageListHeaderProps) {
<div class="connection-status-shortcut-action"> <div class="connection-status-shortcut-action">
<button <button
type="button" type="button"
class="connection-status-button" class="connection-status-button command-palette-button"
onClick={props.onCommandPalette} onClick={props.onCommandPalette}
aria-label={t("messageListHeader.commandPalette.ariaLabel")} aria-label={t("messageListHeader.commandPalette.ariaLabel")}
> >

View File

@@ -3,7 +3,6 @@ import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
import { useConfig } from "../stores/preferences"
import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message" import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/message"
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -17,16 +16,18 @@ interface MessagePartProps {
// Other synthetic text parts (tool traces, read outputs, etc.) should be hidden. // Other synthetic text parts (tool traces, read outputs, etc.) should be hidden.
primaryUserTextPartId?: string | null primaryUserTextPartId?: string | null
onRendered?: () => void onRendered?: () => void
} }
export default function MessagePart(props: MessagePartProps) {
export default function MessagePart(props: MessagePartProps) {
const { isDark } = useTheme() const { isDark } = useTheme()
const { preferences } = useConfig()
const partType = () => props.part?.type || "" const partType = () => props.part?.type || ""
const reasoningId = () => `reasoning-${props.part?.id || ""}` const reasoningId = () => `reasoning-${props.part?.id || ""}`
const isReasoningExpanded = () => isItemExpanded(reasoningId()) const isReasoningExpanded = () => isItemExpanded(reasoningId())
const isAssistantMessage = () => props.messageType === "assistant" const isAssistantMessage = () => props.messageType === "assistant"
const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text") const textContainerClass = () => (isAssistantMessage() ? "message-text message-text-assistant" : "message-text")
const markdownContainerClass = () => "message-text message-text-assistant"
const textContainerRole = () => props.messageType || "assistant"
const shouldHideTextPart = () => { const shouldHideTextPart = () => {
const part = props.part const part = props.part
@@ -57,6 +58,11 @@ interface MessagePartProps {
return "" return ""
} }
const canRenderMarkdown = () => {
const id = (props.part as unknown as { id?: unknown })?.id
return typeof id === "string" && id.length > 0
}
function reasoningSegmentHasText(segment: unknown): boolean { function reasoningSegmentHasText(segment: unknown): boolean {
if (typeof segment === "string") { if (typeof segment === "string") {
return segment.trim().length > 0 return segment.trim().length > 0
@@ -91,20 +97,28 @@ interface MessagePartProps {
const createTextPartForMarkdown = (): TextPart => { const createTextPartForMarkdown = (): TextPart => {
const part = props.part const part = props.part
if ((part.type === "text" || part.type === "reasoning") && typeof part.text === "string") { if (part.type === "text" && typeof part.text === "string") {
// Pass through the original part so `renderCache` updates persist.
return part as unknown as TextPart
}
if (part.type === "reasoning" && typeof (part as any).text === "string") {
// Reasoning parts render as markdown in some views; normalize to TextPart.
return { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: part.text, text: (part as any).text,
synthetic: part.type === "text" ? part.synthetic : false, synthetic: false,
version: (part as { version?: number }).version version: (part as { version?: number }).version,
renderCache: (part as any).renderCache,
} }
} }
return { return {
id: part.id, id: part.id,
type: "text", type: "text",
text: "", text: "",
synthetic: false synthetic: false,
} }
} }
@@ -117,22 +131,18 @@ interface MessagePartProps {
<Switch> <Switch>
<Match when={partType() === "text"}> <Match when={partType() === "text"}>
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div class={textContainerClass()}> <div class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} data-role={textContainerRole()}>
<Show <Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}>
when={isAssistantMessage()} <Markdown
fallback={<span class="text-primary">{plainTextContent()}</span>} part={createTextPartForMarkdown()}
> instanceId={props.instanceId}
<Markdown sessionId={props.sessionId}
part={createTextPartForMarkdown()} isDark={isDark()}
instanceId={props.instanceId} size={isAssistantMessage() ? "tight" : "base"}
sessionId={props.sessionId} onRendered={props.onRendered}
isDark={isDark()} />
size={isAssistantMessage() ? "tight" : "base"} </Show>
onRendered={props.onRendered} </div>
/>
</Show>
</div>
</Show> </Show>
</Match> </Match>

View File

@@ -1,12 +1,18 @@
import type { Component } from "solid-js" import type { Component } from "solid-js"
import MessageBlock from "./message-block" import MessageBlock from "./message-block"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
interface MessagePreviewProps { interface MessagePreviewProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
messageId: string messageId: string
store: () => InstanceMessageStore store: () => InstanceMessageStore
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
const MessagePreview: Component<MessagePreviewProps> = (props) => { const MessagePreview: Component<MessagePreviewProps> = (props) => {
@@ -24,6 +30,11 @@ const MessagePreview: Component<MessagePreviewProps> = (props) => {
showThinking={() => false} showThinking={() => false}
thinkingDefaultExpanded={() => false} thinkingDefaultExpanded={() => false}
showUsageMetrics={() => false} showUsageMetrics={() => false}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage}
/> />
</div> </div>
) )

View File

@@ -1,4 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { Show, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js"
import { CheckSquare, Trash, X } from "lucide-solid"
import Kbd from "./kbd" import Kbd from "./kbd"
import MessageBlockList, { getMessageAnchorId } from "./message-block-list" import MessageBlockList, { getMessageAnchorId } from "./message-block-list"
import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline" import MessageTimeline, { buildTimelineSegments, type TimelineSegment } from "./message-timeline"
@@ -9,7 +10,10 @@ import { useScrollCache } from "../lib/hooks/use-scroll-cache"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import { copyToClipboard } from "../lib/clipboard" import { copyToClipboard } from "../lib/clipboard"
import { showToastNotification } from "../lib/notifications" import { showToastNotification } from "../lib/notifications"
import { showAlertDialog } from "../stores/alerts"
import { deleteMessage } from "../stores/session-actions"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { DeleteHoverState } from "../types/delete-hover"
const SCROLL_SCOPE = "session" const SCROLL_SCOPE = "session"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 48
@@ -23,6 +27,7 @@ export interface MessageSectionProps {
sessionId: string sessionId: string
loading?: boolean loading?: boolean
onRevert?: (messageId: string) => void onRevert?: (messageId: string) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
onFork?: (messageId?: string) => void onFork?: (messageId?: string) => void
registerScrollToBottom?: (fn: () => void) => void registerScrollToBottom?: (fn: () => void) => void
showSidebarToggle?: boolean showSidebarToggle?: boolean
@@ -145,6 +150,63 @@ export default function MessageSection(props: MessageSectionProps) {
} }
} }
const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null) const [activeMessageId, setActiveMessageId] = createSignal<string | null>(null)
const [deleteHover, setDeleteHover] = createSignal<DeleteHoverState>({ kind: "none" })
const [selectedForDeletion, setSelectedForDeletion] = createSignal<Set<string>>(new Set<string>())
const isDeleteMode = createMemo(() => selectedForDeletion().size > 0)
const selectedDeleteCount = createMemo(() => selectedForDeletion().size)
const isMessageSelectedForDeletion = (messageId: string) => selectedForDeletion().has(messageId)
const setMessageSelectedForDeletion = (messageId: string, selected: boolean) => {
if (!messageId) return
setSelectedForDeletion((prev) => {
const next = new Set(prev)
if (selected) {
next.add(messageId)
} else {
next.delete(messageId)
}
return next
})
}
const clearDeleteMode = () => {
setSelectedForDeletion(new Set<string>())
setDeleteHover({ kind: "none" })
}
const selectAllForDeletion = () => {
setSelectedForDeletion(new Set<string>(messageIds()))
}
const deleteSelectedMessages = async () => {
const selected = selectedForDeletion()
if (selected.size === 0) return
const idsInSessionOrder = messageIds()
const toDelete: string[] = []
for (let idx = idsInSessionOrder.length - 1; idx >= 0; idx -= 1) {
const id = idsInSessionOrder[idx]
if (selected.has(id)) {
toDelete.push(id)
}
}
try {
for (const messageId of toDelete) {
await deleteMessage(props.instanceId, props.sessionId, messageId)
}
clearDeleteMode()
} catch (error) {
showAlertDialog(t("messageSection.bulkDelete.failedMessage"), {
title: t("messageSection.bulkDelete.failedTitle"),
detail: error instanceof Error ? error.message : String(error),
variant: "error",
})
}
}
const changeToken = createMemo(() => String(sessionRevision())) const changeToken = createMemo(() => String(sessionRevision()))
const isActive = createMemo(() => props.isActive !== false) const isActive = createMemo(() => props.isActive !== false)
@@ -167,6 +229,7 @@ export default function MessageSection(props: MessageSectionProps) {
const [autoScroll, setAutoScroll] = createSignal(true) const [autoScroll, setAutoScroll] = createSignal(true)
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false) const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false) const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
const [topSentinelVisible, setTopSentinelVisible] = createSignal(true) const [topSentinelVisible, setTopSentinelVisible] = createSignal(true)
const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true) const [bottomSentinelVisible, setBottomSentinelVisible] = createSignal(true)
const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null) const [quoteSelection, setQuoteSelection] = createSignal<{ text: string; top: number; left: number } | null>(null)
@@ -851,7 +914,10 @@ export default function MessageSection(props: MessageSectionProps) {
return ( return (
<div class="message-stream-container"> <div class="message-stream-container">
<div class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}> <div
class={`message-layout${hasTimelineSegments() ? " message-layout--with-timeline" : ""}`}
data-scroll-buttons={scrollButtonsCount()}
>
<div class="message-stream-shell" ref={setShellElement}> <div class="message-stream-shell" ref={setShellElement}>
<div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}> <div class="message-stream" ref={setContainerRef} onScroll={handleScroll} onMouseUp={handleStreamMouseUp}>
<div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} /> <div ref={setTopSentinel} aria-hidden="true" style={{ height: "1px" }} />
@@ -897,8 +963,13 @@ export default function MessageSection(props: MessageSectionProps) {
scrollContainer={scrollElement} scrollContainer={scrollElement}
loading={props.loading} loading={props.loading}
onRevert={props.onRevert} onRevert={props.onRevert}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
onFork={props.onFork} onFork={props.onFork}
onContentRendered={handleContentRendered} onContentRendered={handleContentRendered}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
selectedMessageIds={selectedForDeletion}
onToggleSelectedMessage={setMessageSelectedForDeletion}
setBottomSentinel={setBottomSentinel} setBottomSentinel={setBottomSentinel}
suspendMeasurements={() => !isActive()} suspendMeasurements={() => !isActive()}
/> />
@@ -957,9 +1028,56 @@ export default function MessageSection(props: MessageSectionProps) {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()} showToolSegments={showTimelineToolsPreference()}
deleteHover={deleteHover}
onDeleteHoverChange={setDeleteHover}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={selectedForDeletion}
onToggleSelectedMessage={setMessageSelectedForDeletion}
/> />
</div> </div>
</Show> </Show>
<Show when={isDeleteMode()}>
<div
class="message-delete-mode-toolbar"
role="toolbar"
aria-label={t("messageSection.bulkDelete.toolbarAriaLabel", { count: selectedDeleteCount() })}
>
<span class="message-delete-mode-count" aria-hidden="true">
{selectedDeleteCount()}
</span>
<button
type="button"
class="message-delete-mode-button"
onClick={() => void deleteSelectedMessages()}
title={t("messageSection.bulkDelete.deleteSelectedTitle")}
aria-label={t("messageSection.bulkDelete.deleteSelectedTitle")}
>
<Trash class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={selectAllForDeletion}
title={t("messageSection.bulkDelete.selectAllTitle")}
aria-label={t("messageSection.bulkDelete.selectAllTitle")}
>
<CheckSquare class="w-4 h-4" aria-hidden="true" />
</button>
<button
type="button"
class="message-delete-mode-button"
onClick={clearDeleteMode}
title={t("messageSection.bulkDelete.cancelTitle")}
aria-label={t("messageSection.bulkDelete.cancelTitle")}
>
<X class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</Show>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, onCleanup, on, untrack, type Component } from "solid-js"
import MessagePreview from "./message-preview" import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import type { ClientPart } from "../types/message" import type { ClientPart } from "../types/message"
@@ -7,6 +7,7 @@ import { buildRecordDisplayData } from "../stores/message-v2/record-display-cach
import { getToolIcon } from "./tool-call/utils" import { getToolIcon } from "./tool-call/utils"
import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid" import { User as UserIcon, Bot as BotIcon, FoldVertical, ShieldAlert } from "lucide-solid"
import { useI18n } from "../lib/i18n" import { useI18n } from "../lib/i18n"
import type { DeleteHoverState } from "../types/delete-hover"
export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction" export type TimelineSegmentType = "user" | "assistant" | "tool" | "compaction"
@@ -19,6 +20,8 @@ export interface TimelineSegment {
shortLabel?: string shortLabel?: string
variant?: "auto" | "manual" variant?: "auto" | "manual"
toolPartIds?: string[] toolPartIds?: string[]
partIds?: string[]
partId?: string
} }
interface MessageTimelineProps { interface MessageTimelineProps {
@@ -28,6 +31,11 @@ interface MessageTimelineProps {
instanceId: string instanceId: string
sessionId: string sessionId: string
showToolSegments?: boolean showToolSegments?: boolean
deleteHover?: () => DeleteHoverState
onDeleteHoverChange?: (state: DeleteHoverState) => void
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
} }
const MAX_TOOLTIP_LENGTH = 220 const MAX_TOOLTIP_LENGTH = 220
@@ -38,10 +46,7 @@ interface PendingSegment {
type: TimelineSegmentType type: TimelineSegmentType
texts: string[] texts: string[]
reasoningTexts: string[] reasoningTexts: string[]
toolTitles: string[] partIds: string[]
toolTypeLabels: string[]
toolIcons: string[]
toolPartIds: string[]
hasPrimaryText: boolean hasPrimaryText: boolean
} }
@@ -171,17 +176,12 @@ export function buildTimelineSegments(
pending = null pending = null
return return
} }
const isToolSegment = pending.type === "tool" const label = segmentLabel(pending.type)
const label = isToolSegment const shortLabel = undefined
? pending.toolTypeLabels[0] || segmentLabel("tool") const tooltip = formatTextsTooltip(
: segmentLabel(pending.type) [...pending.texts, ...pending.reasoningTexts],
const shortLabel = isToolSegment ? pending.toolIcons[0] || getToolIcon("tool") : undefined pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
const tooltip = isToolSegment )
? formatToolTooltip(pending.toolTitles, t)
: formatTextsTooltip(
[...pending.texts, ...pending.reasoningTexts],
pending.type === "user" ? t("messageTimeline.tooltip.userFallback") : t("messageTimeline.tooltip.assistantFallback"),
)
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
@@ -190,7 +190,7 @@ export function buildTimelineSegments(
label, label,
tooltip, tooltip,
shortLabel, shortLabel,
toolPartIds: isToolSegment ? pending.toolPartIds : undefined, partIds: pending.partIds,
}) })
segmentIndex += 1 segmentIndex += 1
pending = null pending = null
@@ -199,7 +199,13 @@ export function buildTimelineSegments(
const ensureSegment = (type: TimelineSegmentType): PendingSegment => { const ensureSegment = (type: TimelineSegmentType): PendingSegment => {
if (!pending || pending.type !== type) { if (!pending || pending.type !== type) {
flushPending() flushPending()
pending = { type, texts: [], reasoningTexts: [], toolTitles: [], toolTypeLabels: [], toolIcons: [], toolPartIds: [], hasPrimaryText: type !== "assistant" } pending = {
type,
texts: [],
reasoningTexts: [],
partIds: [],
hasPrimaryText: type !== "assistant",
}
} }
return pending! return pending!
} }
@@ -211,14 +217,20 @@ export function buildTimelineSegments(
if (!part || typeof part !== "object") continue if (!part || typeof part !== "object") continue
if (part.type === "tool") { if (part.type === "tool") {
const target = ensureSegment("tool") flushPending()
const toolPart = part as ToolCallPart const toolPart = part as ToolCallPart
target.toolTitles.push(getToolTitle(toolPart, t)) const partId = typeof toolPart.id === "string" ? toolPart.id : ""
target.toolTypeLabels.push(getToolTypeLabel(toolPart, t)) const title = getToolTitle(toolPart, t)
target.toolIcons.push(getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool")) result.push({
if (typeof toolPart.id === "string" && toolPart.id.length > 0) { id: `${record.id}:${segmentIndex}`,
target.toolPartIds.push(toolPart.id) messageId: record.id,
} type: "tool",
label: getToolTypeLabel(toolPart, t) || segmentLabel("tool"),
tooltip: formatToolTooltip([title], t),
shortLabel: getToolIcon(typeof toolPart.tool === "string" ? toolPart.tool : "tool"),
toolPartIds: partId ? [partId] : undefined,
})
segmentIndex += 1
continue continue
} }
@@ -228,6 +240,9 @@ export function buildTimelineSegments(
const target = ensureSegment(defaultContentType) const target = ensureSegment(defaultContentType)
if (target) { if (target) {
target.reasoningTexts.push(text) target.reasoningTexts.push(text)
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
} }
continue continue
} }
@@ -235,6 +250,7 @@ export function buildTimelineSegments(
if (part.type === "compaction") { if (part.type === "compaction") {
flushPending() flushPending()
const isAuto = Boolean((part as any)?.auto) const isAuto = Boolean((part as any)?.auto)
const partId = typeof (part as any)?.id === "string" ? ((part as any).id as string) : ""
result.push({ result.push({
id: `${record.id}:${segmentIndex}`, id: `${record.id}:${segmentIndex}`,
messageId: record.id, messageId: record.id,
@@ -242,6 +258,7 @@ export function buildTimelineSegments(
label: segmentLabel("compaction"), label: segmentLabel("compaction"),
tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"), tooltip: isAuto ? t("messageTimeline.tooltip.compaction.auto") : t("messageTimeline.tooltip.compaction.manual"),
variant: isAuto ? "auto" : "manual", variant: isAuto ? "auto" : "manual",
partId,
}) })
segmentIndex += 1 segmentIndex += 1
continue continue
@@ -257,6 +274,9 @@ export function buildTimelineSegments(
if (target) { if (target) {
target.texts.push(text) target.texts.push(text)
target.hasPrimaryText = true target.hasPrimaryText = true
if (typeof (part as any).id === "string" && (part as any).id.length > 0) {
target.partIds.push((part as any).id)
}
} }
} }
@@ -278,6 +298,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
let hoverTimer: number | null = null let hoverTimer: number | null = null
let closeTimer: number | null = null let closeTimer: number | null = null
const showTools = () => props.showToolSegments ?? true const showTools = () => props.showToolSegments ?? true
const deleteHover = () => props.deleteHover?.() ?? { kind: "none" as const }
const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => { const registerButtonRef = (segmentId: string, element: HTMLButtonElement | null) => {
if (element) { if (element) {
@@ -350,11 +371,9 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
clearCloseTimer() clearCloseTimer()
}) })
createEffect(() => { createEffect(on(() => props.activeMessageId, (activeId) => {
const activeId = props.activeMessageId
if (!activeId) return if (!activeId) return
const targetSegment = props.segments.find((segment) => segment.messageId === activeId) const targetSegment = untrack(() => props.segments).find((segment) => segment.messageId === activeId)
if (!targetSegment) return if (!targetSegment) return
const element = buttonRefs.get(targetSegment.id) const element = buttonRefs.get(targetSegment.id)
if (!element) return if (!element) return
@@ -366,7 +385,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
window.clearTimeout(timer) window.clearTimeout(timer)
} }
}) })
}) }))
createEffect(() => { createEffect(() => {
const element = tooltipElement() const element = tooltipElement()
@@ -398,6 +417,28 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
onCleanup(() => buttonRefs.delete(segment.id)) onCleanup(() => buttonRefs.delete(segment.id))
const isActive = () => props.activeMessageId === segment.messageId const isActive = () => props.activeMessageId === segment.messageId
const isDeleteHovered = () => {
const hover = deleteHover() as DeleteHoverState
const selected = props.selectedMessageIds?.() ?? new Set<string>()
if (selected.has(segment.messageId)) {
return true
}
if (hover.kind === "message") {
return hover.messageId === segment.messageId
}
if (hover.kind === "deleteUpTo") {
const ids = store().getSessionMessageIds(props.sessionId)
const targetIndex = ids.indexOf(hover.messageId)
if (targetIndex === -1) return false
const segmentIndex = ids.indexOf(segment.messageId)
if (segmentIndex === -1) return false
return segmentIndex >= targetIndex
}
return false
}
const hasActivePermission = () => { const hasActivePermission = () => {
if (segment.type !== "tool") return false if (segment.type !== "tool") return false
const partIds = segment.toolPartIds ?? [] const partIds = segment.toolPartIds ?? []
@@ -409,7 +450,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return false return false
} }
const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission()) const isHidden = () => segment.type === "tool" && !(showTools() || isActive() || hasActivePermission() || isDeleteHovered())
const shortLabelContent = () => { const shortLabelContent = () => {
if (segment.type === "tool") { if (segment.type === "tool") {
@@ -429,15 +470,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return ( return (
<button <button
ref={(el) => registerButtonRef(segment.id, el)} ref={(el) => registerButtonRef(segment.id, el)}
type="button" type="button"
data-variant={segment.variant} data-variant={segment.variant}
class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`} class={`message-timeline-segment message-timeline-${segment.type} ${hasActivePermission() ? "message-timeline-segment-permission" : ""} ${segment.type === "compaction" ? `message-timeline-compaction-${segment.variant ?? "manual"}` : ""} ${isActive() ? "message-timeline-segment-active" : ""} ${isHidden() ? "message-timeline-segment-hidden" : ""}`}
aria-current={isActive() ? "true" : undefined} data-delete-hover={isDeleteHovered() ? "true" : undefined}
aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)} aria-current={isActive() ? "true" : undefined}
onMouseEnter={(event) => handleMouseEnter(segment, event)} aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
> >
<span class="message-timeline-label message-timeline-label-full">{segment.label}</span> <span class="message-timeline-label message-timeline-label-full">{segment.label}</span>
@@ -462,6 +505,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={props.sessionId} sessionId={props.sessionId}
store={store} store={store}
deleteHover={props.deleteHover}
onDeleteHoverChange={props.onDeleteHoverChange}
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds}
/> />
</div> </div>
) )

View File

@@ -176,15 +176,26 @@ export default function PromptInput(props: PromptInputProps) {
), ),
) )
onMount(() => { const isCoarsePointer = () => {
if (typeof window === "undefined") return false
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
}
createEffect(() => {
// Scope global "type-to-focus" behavior to the active, visible prompt only.
if (typeof document === "undefined") return
if (isCoarsePointer()) return
if (props.isActive === false) return
if (props.disabled) return
const handleGlobalKeyDown = (e: KeyboardEvent) => { const handleGlobalKeyDown = (e: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement const activeElement = document.activeElement as HTMLElement | null
const isInputElement = const isInputElement =
activeElement?.tagName === "INPUT" || activeElement?.tagName === "INPUT" ||
activeElement?.tagName === "TEXTAREA" || activeElement?.tagName === "TEXTAREA" ||
activeElement?.tagName === "SELECT" || activeElement?.tagName === "SELECT" ||
activeElement?.isContentEditable Boolean(activeElement?.isContentEditable)
if (isInputElement) return if (isInputElement) return
@@ -192,16 +203,25 @@ export default function PromptInput(props: PromptInputProps) {
if (isModifierKey) return if (isModifierKey) return
const isSpecialKey = const isSpecialKey =
e.key === "Tab" || e.key === "Enter" || e.key.startsWith("Arrow") || e.key === "Backspace" || e.key === "Delete" e.key === "Tab" ||
e.key === "Enter" ||
e.key.startsWith("Arrow") ||
e.key === "Backspace" ||
e.key === "Delete"
if (isSpecialKey) return if (isSpecialKey) return
if (e.key.length === 1 && textareaRef && !props.disabled) { const textarea = textareaRef
textareaRef.focus() if (!textarea || textarea.disabled) return
// In session cache mode inactive panes are display:none; avoid stealing focus.
if (textarea.offsetParent === null) return
if (e.key.length === 1) {
textarea.focus()
} }
} }
document.addEventListener("keydown", handleGlobalKeyDown) document.addEventListener("keydown", handleGlobalKeyDown)
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleGlobalKeyDown) document.removeEventListener("keydown", handleGlobalKeyDown)
}) })
@@ -331,7 +351,9 @@ export default function PromptInput(props: PromptInputProps) {
const blockquote = lines.map((line) => `> ${line}`).join("\n") const blockquote = lines.map((line) => `> ${line}`).join("\n")
if (!blockquote) return if (!blockquote) return
insertBlockContent(`${blockquote}\n`) // End the blockquote with a blank line so the user's next line
// doesn't get parsed as a lazy continuation of the quote.
insertBlockContent(`${blockquote}\n\n`)
} }
function insertCodeSelection(rawText: string) { function insertCodeSelection(rawText: string) {
@@ -435,7 +457,7 @@ export default function PromptInput(props: PromptInputProps) {
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
disabled={props.disabled} disabled={props.disabled}
rows={expandState() === "expanded" ? 15 : 4} rows={expandState() === "expanded" ? (props.compactLayout ? 10 : 15) : 3}
spellcheck={false} spellcheck={false}
autocorrect="off" autocorrect="off"
autoCapitalize="off" autoCapitalize="off"

View File

@@ -17,6 +17,12 @@ export interface PromptInputProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
sessionId: string sessionId: string
// Used to scope global "type-to-focus" behavior.
isActive?: boolean
// Phone/tablet layouts should keep the expanded prompt more compact.
compactLayout?: boolean
onSend: (prompt: string, attachments: Attachment[]) => Promise<void> onSend: (prompt: string, attachments: Attachment[]) => Promise<void>
onRunShell?: (command: string) => Promise<void> onRunShell?: (command: string) => Promise<void>
disabled?: boolean disabled?: boolean

View File

@@ -23,6 +23,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
const [meta, setMeta] = createSignal<ServerMeta | null>(null) const [meta, setMeta] = createSignal<ServerMeta | null>(null)
const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null) const [authStatus, setAuthStatus] = createSignal<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean } | null>(null)
const [loading, setLoading] = createSignal(false) const [loading, setLoading] = createSignal(false)
const [applyingListeningMode, setApplyingListeningMode] = createSignal(false)
const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({}) const [qrCodes, setQrCodes] = createSignal<Record<string, string>>({})
const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null) const [expandedUrl, setExpandedUrl] = createSignal<string | null>(null)
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
@@ -88,6 +89,10 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return return
} }
if (applyingListeningMode()) {
return
}
const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), { const confirmed = await showConfirmDialog(t("remoteAccess.listeningMode.restartConfirm.message"), {
title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"), title: allow ? t("remoteAccess.listeningMode.restartConfirm.title.all") : t("remoteAccess.listeningMode.restartConfirm.title.local"),
variant: "warning", variant: "warning",
@@ -100,12 +105,21 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
return return
} }
setListeningMode(targetMode) setApplyingListeningMode(true)
const restarted = await restartCli() setError(null)
if (!restarted) { try {
setError(t("remoteAccess.restart.errorManual")) // Important: await the config patch before restart so Electron reads the updated mode from disk.
} else { await setListeningMode(targetMode)
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) const restarted = await restartCli()
if (!restarted) {
setError(t("remoteAccess.restart.errorManual"))
} else {
setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev))
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplyingListeningMode(false)
} }
void refreshMeta() void refreshMeta()
@@ -196,6 +210,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
onChange={(nextChecked) => { onChange={(nextChecked) => {
void handleAllowConnectionsChange(nextChecked) void handleAllowConnectionsChange(nextChecked)
}} }}
disabled={loading() || applyingListeningMode()}
> >
<Switch.Input /> <Switch.Input />
<Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}> <Switch.Control class="remote-toggle-switch" data-checked={allowExternalConnections()}>

View File

@@ -10,6 +10,7 @@ import { getAttachments, removeAttachment } from "../../stores/attachments"
import { instances } from "../../stores/instances" import { instances } from "../../stores/instances"
import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions" import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand, abortSession } from "../../stores/sessions"
import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status" import { isSessionBusy as getSessionBusyStatus } from "../../stores/session-status"
import { deleteMessage } from "../../stores/session-actions"
import { showAlertDialog } from "../../stores/alerts" import { showAlertDialog } from "../../stores/alerts"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { requestData } from "../../lib/opencode-api" import { requestData } from "../../lib/opencode-api"
@@ -28,6 +29,8 @@ interface SessionViewProps {
instanceId: string instanceId: string
instanceFolder: string instanceFolder: string
escapeInDebounce: boolean escapeInDebounce: boolean
isPhoneLayout?: boolean
compactPromptLayout?: boolean
showSidebarToggle?: boolean showSidebarToggle?: boolean
onSidebarToggle?: () => void onSidebarToggle?: () => void
forceCompactStatusLayout?: boolean forceCompactStatusLayout?: boolean
@@ -76,6 +79,9 @@ export const SessionView: Component<SessionViewProps> = (props) => {
(isActive) => { (isActive) => {
if (!isActive) return if (!isActive) return
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
if (props.isPhoneLayout) return
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.) // Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
if (typeof document === "undefined") return if (typeof document === "undefined") return
const activeEl = document.activeElement as HTMLElement | null const activeEl = document.activeElement as HTMLElement | null
@@ -220,6 +226,35 @@ export const SessionView: Component<SessionViewProps> = (props) => {
} }
} }
async function handleDeleteMessagesUpTo(messageId: string) {
const ids = messageStore().getSessionMessageIds(props.sessionId)
const index = ids.indexOf(messageId)
if (index === -1) return
const restoredText = getUserMessageText(messageId)
const toDelete = ids.slice(index)
try {
for (let idx = toDelete.length - 1; idx >= 0; idx -= 1) {
await deleteMessage(props.instanceId, props.sessionId, toDelete[idx])
}
} catch (error) {
log.error("Failed to delete messages up to", error)
showAlertDialog(t("sessionView.alerts.deleteUpToFailed.message"), {
title: t("sessionView.alerts.deleteUpToFailed.title"),
variant: "error",
})
} finally {
if (restoredText) {
if (promptInputApi) {
promptInputApi.setPromptText(restoredText, { focus: true })
} else {
pendingPromptText = restoredText
}
}
}
}
async function handleFork(messageId?: string) { async function handleFork(messageId?: string) {
if (!messageId) { if (!messageId) {
log.warn("Fork requires a user message id") log.warn("Fork requires a user message id")
@@ -278,10 +313,11 @@ export const SessionView: Component<SessionViewProps> = (props) => {
<MessageSection <MessageSection
instanceId={props.instanceId} instanceId={props.instanceId}
sessionId={activeSession.id} sessionId={activeSession.id}
loading={messagesLoading()} loading={messagesLoading()}
onRevert={handleRevert} onRevert={handleRevert}
onFork={handleFork} onDeleteMessagesUpTo={handleDeleteMessagesUpTo}
isActive={props.isActive} onFork={handleFork}
isActive={props.isActive}
registerScrollToBottom={(fn) => { registerScrollToBottom={(fn) => {
scrollToBottomHandle = fn scrollToBottomHandle = fn
if (props.isActive) { if (props.isActive) {
@@ -314,17 +350,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
</Show> </Show>
<PromptInput <PromptInput
instanceId={props.instanceId} instanceId={props.instanceId}
instanceFolder={props.instanceFolder} instanceFolder={props.instanceFolder}
sessionId={activeSession.id} sessionId={activeSession.id}
onSend={handleSendMessage} isActive={props.isActive}
onRunShell={handleRunShell} compactLayout={props.compactPromptLayout}
escapeInDebounce={props.escapeInDebounce} onSend={handleSendMessage}
isSessionBusy={sessionBusy()} onRunShell={handleRunShell}
disabled={sessionNeedsInput()} escapeInDebounce={props.escapeInDebounce}
onAbortSession={handleAbortSession} isSessionBusy={sessionBusy()}
registerPromptInputApi={registerPromptInputApi} disabled={sessionNeedsInput()}
/> onAbortSession={handleAbortSession}
registerPromptInputApi={registerPromptInputApi}
/>
</div> </div>
) )
}} }}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils" import { getRelativePath, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./utils"
import { tGlobal } from "../../lib/i18n" import { tGlobal } from "../../lib/i18n"

View File

@@ -1,5 +1,5 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { TextPart } from "../../types/message" import type { TextPart } from "../../types/message"
import { Markdown } from "../markdown" import { Markdown } from "../markdown"
import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types" import type { MarkdownRenderOptions, ToolScrollHelpers } from "./types"

View File

@@ -1,5 +1,5 @@
import { createMemo, Show, For, createEffect, type Accessor } from "solid-js" import { createMemo, Show, For, createEffect, type Accessor } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import { useI18n } from "../../lib/i18n" import { useI18n } from "../../lib/i18n"

View File

@@ -1,5 +1,5 @@
import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
@@ -178,28 +178,116 @@ export const taskRenderer: ToolRenderer = {
void loadMessages(instanceId, id) void loadMessages(instanceId, id)
}) })
const childToolKeys = createMemo(() => { const [childToolKeys, setChildToolKeys] = createSignal<string[]>([])
const id = childSessionId()
if (!id) return [] as string[]
if (!childSessionLoaded()) return [] as string[]
// React to session changes, but do the scan untracked to avoid let indexedSessionId = ""
// subscribing to every message/part node in the store. let indexedMessageCount = 0
let indexedMessageTail = ""
const indexedPartCounts = new Map<string, number>()
function resetChildToolIndex(nextSessionId: string) {
indexedSessionId = nextSessionId
indexedMessageCount = 0
indexedMessageTail = ""
indexedPartCounts.clear()
setChildToolKeys([])
}
function scanMessageToolParts(messageId: string, startIndex: number) {
const record = store.getMessage(messageId)
if (!record) return [] as string[]
const partIds = record.partIds
const keys: string[] = []
for (let idx = startIndex; idx < partIds.length; idx += 1) {
const partId = partIds[idx]
const entry = record.parts?.[partId]
const data = entry?.data
if (!data || (data as any).type !== "tool") continue
keys.push(`${messageId}::${partId}`)
}
indexedPartCounts.set(messageId, partIds.length)
return keys
}
function fullRescanChildTools(sessionId: string, messageIds: string[]) {
indexedSessionId = sessionId
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
indexedPartCounts.clear()
const nextKeys: string[] = []
for (const messageId of messageIds) {
nextKeys.push(...scanMessageToolParts(messageId, 0))
}
setChildToolKeys(nextKeys)
}
createEffect(() => {
const id = childSessionId()
const loaded = childSessionLoaded()
if (!id || !loaded) {
if (indexedSessionId) {
resetChildToolIndex("")
}
return
}
// We use the session revision as the reactive change point, but avoid
// rescanning the entire session on every update.
store.getSessionRevision(id) store.getSessionRevision(id)
return untrack(() => {
untrack(() => {
const messageIds = store.getSessionMessageIds(id) const messageIds = store.getSessionMessageIds(id)
const keys: string[] = []
for (const messageId of messageIds) { if (!indexedSessionId || indexedSessionId !== id) {
const record = store.getMessage(messageId) fullRescanChildTools(id, messageIds)
if (!record) continue return
for (const partId of record.partIds) { }
const entry = record.parts?.[partId]
const data = entry?.data // Detect structural changes (reorder/shrink) and fall back to a full rescan.
if (!data || (data as any).type !== "tool") continue if (messageIds.length < indexedMessageCount) {
keys.push(`${messageId}::${partId}`) fullRescanChildTools(id, messageIds)
return
}
if (indexedMessageCount > 0) {
const expectedTailIndex = indexedMessageCount - 1
if (expectedTailIndex >= 0 && messageIds[expectedTailIndex] !== indexedMessageTail) {
fullRescanChildTools(id, messageIds)
return
} }
} }
return keys
const appendedKeys: string[] = []
// Scan any new messages appended since last index.
for (let idx = indexedMessageCount; idx < messageIds.length; idx += 1) {
const messageId = messageIds[idx]
appendedKeys.push(...scanMessageToolParts(messageId, 0))
}
// Scan a small window of recent messages for newly appended parts.
// Deltas typically affect the most recent tool call, so this avoids
// iterating every message on every revision.
const existingCount = Math.min(indexedMessageCount, messageIds.length)
const windowStart = Math.max(0, existingCount - 3)
for (let idx = windowStart; idx < existingCount; idx += 1) {
const messageId = messageIds[idx]
const previousPartCount = indexedPartCounts.get(messageId) ?? 0
const record = store.getMessage(messageId)
const nextPartCount = record?.partIds.length ?? 0
if (nextPartCount > previousPartCount) {
appendedKeys.push(...scanMessageToolParts(messageId, previousPartCount))
}
}
indexedMessageCount = messageIds.length
indexedMessageTail = messageIds[messageIds.length - 1] ?? ""
if (appendedKeys.length > 0) {
setChildToolKeys((prev) => [...prev, ...appendedKeys])
}
}) })
}) })
const promptContent = createMemo(() => { const promptContent = createMemo(() => {
@@ -287,7 +375,9 @@ export const taskRenderer: ToolRenderer = {
content: promptContent()!, content: promptContent()!,
cacheKey: "task:prompt", cacheKey: "task:prompt",
disableScrollTracking: true, disableScrollTracking: true,
disableHighlight: true, // Always use the normal markdown render path for prompt (even while running)
// so the prompt doesn't visually change between running/completed states.
disableHighlight: false,
})} })}
</div> </div>
</section> </section>
@@ -352,7 +442,7 @@ export const taskRenderer: ToolRenderer = {
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
} }
> >
<div class="tool-call-task-summary"> <div class="tool-call-task-summary">
<For each={childToolKeys()}> <For each={childToolKeys()}>
{(key) => ( {(key) => (
<Show when={renderToolCall}> <Show when={renderToolCall}>

View File

@@ -1,5 +1,5 @@
import { For, Show } from "solid-js" import { For, Show } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRenderer } from "../types" import type { ToolRenderer } from "../types"
import { readToolStatePayload } from "../utils" import { readToolStatePayload } from "../utils"
import { useI18n, tGlobal } from "../../../lib/i18n" import { useI18n, tGlobal } from "../../../lib/i18n"

View File

@@ -1,4 +1,4 @@
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types" import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils" import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
import { enMessages } from "../../lib/i18n/messages/en" import { enMessages } from "../../lib/i18n/messages/en"

View File

@@ -1,5 +1,5 @@
import type { Accessor, JSXElement } from "solid-js" import type { Accessor, JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { ClientPart } from "../../types/message" import type { ClientPart } from "../../types/message"
export type ToolCallPart = Extract<ClientPart, { type: "tool" }> export type ToolCallPart = Extract<ClientPart, { type: "tool" }>

View File

@@ -1,15 +1,15 @@
import { isRenderableDiffText } from "../../lib/diff-utils" import { isRenderableDiffText } from "../../lib/diff-utils"
import { getLanguageFromPath } from "../../lib/markdown" import { getLanguageFromPath } from "../../lib/markdown"
import type { ToolState } from "@opencode-ai/sdk" import type { ToolState } from "@opencode-ai/sdk/v2"
import type { DiffPayload } from "./types" import type { DiffPayload } from "./types"
import { getLogger } from "../../lib/logger" import { getLogger } from "../../lib/logger"
import { tGlobal } from "../../lib/i18n" import { tGlobal } from "../../lib/i18n"
const log = getLogger("session") const log = getLogger("session")
export type ToolStateRunning = import("@opencode-ai/sdk").ToolStateRunning export type ToolStateRunning = import("@opencode-ai/sdk/v2").ToolStateRunning
export type ToolStateCompleted = import("@opencode-ai/sdk").ToolStateCompleted export type ToolStateCompleted = import("@opencode-ai/sdk/v2").ToolStateCompleted
export type ToolStateError = import("@opencode-ai/sdk").ToolStateError export type ToolStateError = import("@opencode-ai/sdk/v2").ToolStateError
export const diffCapableTools = new Set(["edit", "patch"]) export const diffCapableTools = new Set(["edit", "patch"])

View File

@@ -287,13 +287,14 @@ const UnifiedPicker: Component<UnifiedPickerProps> = (props) => {
if (mode() !== "mention") return if (mode() !== "mention") return
const query = props.searchQuery.toLowerCase() const query = props.searchQuery.toLowerCase()
const visibleAgents = props.agents.filter((agent) => !agent.hidden)
const filtered = query const filtered = query
? props.agents.filter( ? visibleAgents.filter(
(agent) => (agent) =>
agent.name.toLowerCase().includes(query) || agent.name.toLowerCase().includes(query) ||
(agent.description && agent.description.toLowerCase().includes(query)), (agent.description && agent.description.toLowerCase().includes(query)),
) )
: props.agents : visibleAgents
setFilteredAgents(filtered) setFilteredAgents(filtered)
}) })

View File

@@ -1,6 +1,6 @@
import { createSignal, onMount } from "solid-js" import { createSignal, onMount } from "solid-js"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { Preferences, ExpansionPreference } from "../../stores/preferences" import type { Preferences, ExpansionPreference, ToolInputsVisibilityPreference } from "../../stores/preferences"
import { createCommandRegistry, type Command } from "../commands" import { createCommandRegistry, type Command } from "../commands"
import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances" import { instances, activeInstanceId, setActiveInstanceId } from "../../stores/instances"
import type { ClientPart, MessageInfo } from "../../types/message" import type { ClientPart, MessageInfo } from "../../types/message"
@@ -38,6 +38,7 @@ export interface UseCommandsOptions {
setToolOutputExpansion: (mode: ExpansionPreference) => void setToolOutputExpansion: (mode: ExpansionPreference) => void
setDiagnosticsExpansion: (mode: ExpansionPreference) => void setDiagnosticsExpansion: (mode: ExpansionPreference) => void
setThinkingBlocksExpansion: (mode: ExpansionPreference) => void setThinkingBlocksExpansion: (mode: ExpansionPreference) => void
setToolInputsVisibility: (mode: ToolInputsVisibilityPreference) => void
handleNewInstanceRequest: () => void handleNewInstanceRequest: () => void
handleCloseInstance: (instanceId: string) => Promise<void> handleCloseInstance: (instanceId: string) => Promise<void>
handleNewSession: (instanceId: string) => Promise<void> handleNewSession: (instanceId: string) => Promise<void>
@@ -551,6 +552,29 @@ export function useCommands(options: UseCommandsOptions) {
}, },
}) })
commandRegistry.register({
id: "tool-inputs-visibility",
label: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const state =
mode === "expanded"
? tGlobal("commands.common.expanded")
: mode === "collapsed"
? tGlobal("commands.common.collapsed")
: tGlobal("commands.common.hidden")
return tGlobal("commands.toolInputsVisibility.label", { state })
},
description: () => tGlobal("commands.toolInputsVisibility.description"),
category: "System",
keywords: () => splitKeywords("commands.toolInputsVisibility.keywords"),
action: () => {
const mode = options.preferences().toolInputsVisibility || "hidden"
const next: ToolInputsVisibilityPreference =
mode === "hidden" ? "collapsed" : mode === "collapsed" ? "expanded" : "hidden"
options.setToolInputsVisibility(next)
},
})
commandRegistry.register({ commandRegistry.register({
id: "token-usage-visibility", id: "token-usage-visibility",
label: () => { label: () => {

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output", "commands.diagnosticsDefault.description": "Toggle default expansion for diagnostics output",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "Tool Inputs Visibility · {state}",
"commands.toolInputsVisibility.description": "Set default visibility for tool call input arguments",
"commands.toolInputsVisibility.keywords": "tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token Usage Display · {state}", "commands.tokenUsageDisplay.label": "Token Usage Display · {state}",
"commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages", "commands.tokenUsageDisplay.description": "Show or hide token and cost stats for assistant messages",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Open right drawer", "instanceShell.rightDrawer.toggle.open": "Open right drawer",
"instanceShell.rightDrawer.toggle.close": "Close right drawer", "instanceShell.rightDrawer.toggle.close": "Close right drawer",
"instanceShell.fullscreen.enter": "Full screen",
"instanceShell.fullscreen.exit": "Exit full screen",
"instanceShell.metrics.usedLabel": "Used", "instanceShell.metrics.usedLabel": "Used",
"instanceShell.metrics.availableLabel": "Avail", "instanceShell.metrics.availableLabel": "Avail",
@@ -93,11 +96,17 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs", "instanceShell.rightPanel.tabs.ariaLabel": "Right panel tabs",
"instanceShell.rightPanel.actions.refresh": "Refresh", "instanceShell.rightPanel.actions.refresh": "Refresh",
"instanceShell.rightPanel.sections.sessionChanges": "Session Changes", "instanceShell.rightPanel.sections.sessionChanges": "Session Changes",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Files modified in the current session. Shows additions and deletions for each file.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "The agent's roadmap for this session. Tracks tasks, subtasks, and their completion status.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells", "instanceShell.rightPanel.sections.backgroundProcesses": "Background Shells",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Long-running processes started by the agent. You can monitor their output, stop, or terminate them.",
"instanceShell.rightPanel.sections.mcp": "MCP Servers", "instanceShell.rightPanel.sections.mcp": "MCP Servers",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol servers that extend the agent's capabilities with external tools and services.",
"instanceShell.rightPanel.sections.lsp": "LSP Servers", "instanceShell.rightPanel.sections.lsp": "LSP Servers",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocol servers providing code intelligence, diagnostics, and language-specific features.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins that customize the UI and server behavior, adding features beyond MCP and LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.", "instanceShell.sessionChanges.noSessionSelected": "Select a session to view changes.",
"instanceShell.sessionChanges.loading": "Fetching session changes...", "instanceShell.sessionChanges.loading": "Fetching session changes...",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.", "infoView.logs.paused.description": "Enable streaming to watch your OpenCode server activity.",
"infoView.logs.empty.waiting": "Waiting for server output...", "infoView.logs.empty.waiting": "Waiting for server output...",
"infoView.logs.scrollToBottom": "Scroll to bottom", "infoView.logs.scrollToBottom": "Scroll to bottom",
"infoView.dispose.actions.dispose": "Dispose instance",
"infoView.dispose.actions.disposing": "Disposing...",
"infoView.dispose.confirm.title": "Dispose instance?",
"infoView.dispose.confirm.message": "This clears cached per-project state for this directory and reloads the instance.",
"infoView.dispose.confirm.confirmLabel": "Dispose",
"infoView.dispose.confirm.cancelLabel": "Cancel",
"infoView.dispose.toast.success": "Instance disposed. Reloading...",
"infoView.dispose.toast.error": "Failed to dispose instance.",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Go to Session", "messageBlock.tool.goToSession.label": "Go to Session",
"messageBlock.tool.goToSession.title": "Go to session", "messageBlock.tool.goToSession.title": "Go to session",
"messageBlock.tool.goToSession.unavailableTitle": "Session not available yet", "messageBlock.tool.goToSession.unavailableTitle": "Session not available yet",
"messageBlock.tool.deletePart.label": "Delete", "messageBlock.tool.deletePart.label": "Delete Part",
"messageBlock.tool.deletePart.deleting": "Deleting...", "messageBlock.tool.deletePart.deleting": "Deleting...",
"messageBlock.tool.deletePart.title": "Delete this tool call output", "messageBlock.tool.deletePart.title": "Delete this tool call output",
"messageBlock.tool.deletePart.failed.title": "Delete failed", "messageBlock.tool.deletePart.failed.title": "Delete failed",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "You", "messageItem.speaker.you": "You",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revert", "messageItem.actions.revert": "Revert",
"messageItem.actions.revertTitle": "Revert to this message", "messageItem.actions.revertTitle": "Undo changes up to here (deletes messages)",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork from this message", "messageItem.actions.forkTitle": "Fork from this message",
"messageItem.actions.copy": "Copy", "messageItem.actions.copy": "Copy",
"messageItem.actions.copyTitle": "Copy message", "messageItem.actions.copyTitle": "Copy message",
"messageItem.actions.copied": "Copied!", "messageItem.actions.copied": "Copied!",
"messageItem.actions.deleteMessage": "Delete message (doesn't undo changes)",
"messageItem.actions.deleteMessagesUpTo": "Delete messages up to here (doesn't undo changes)",
"messageItem.actions.deletingMessage": "Deleting...",
"messageItem.actions.deleteMessageFailedTitle": "Delete failed",
"messageItem.actions.deleteMessageFailedMessage": "Failed to delete message",
"messageItem.selection.checkboxAriaLabel": "Select message for deletion",
"messageSection.bulkDelete.toolbarAriaLabel": "Selected messages ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Delete selected messages",
"messageSection.bulkDelete.selectAllTitle": "Select all messages",
"messageSection.bulkDelete.cancelTitle": "Cancel selection",
"messageSection.bulkDelete.failedTitle": "Delete failed",
"messageSection.bulkDelete.failedMessage": "Failed to delete selected messages",
"messageItem.status.queued": "QUEUED", "messageItem.status.queued": "QUEUED",
"messageItem.status.generating": "Generating...", "messageItem.status.generating": "Generating...",
"messageItem.status.sending": "Sending...", "messageItem.status.sending": "Sending...",
"messageItem.status.failedToSend": "Message failed to send", "messageItem.status.failedToSend": "Message failed to send",
"messagePart.actions.delete": "Delete", "messagePart.actions.delete": "Delete Part",
"messagePart.actions.deleting": "Deleting...", "messagePart.actions.deleting": "Deleting...",
"messagePart.actions.deleteTitle": "Delete this item", "messagePart.actions.deleteTitle": "Delete this item",
"messagePart.actions.deleteFailedTitle": "Delete failed", "messagePart.actions.deleteFailedTitle": "Delete failed",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Stop failed", "sessionView.alerts.abortFailed.title": "Stop failed",
"sessionView.alerts.revertFailed.message": "Failed to revert to message", "sessionView.alerts.revertFailed.message": "Failed to revert to message",
"sessionView.alerts.revertFailed.title": "Revert failed", "sessionView.alerts.revertFailed.title": "Revert failed",
"sessionView.alerts.deleteUpToFailed.message": "Failed to delete messages",
"sessionView.alerts.deleteUpToFailed.title": "Delete failed",
"sessionView.alerts.forkFailed.message": "Failed to fork session", "sessionView.alerts.forkFailed.message": "Failed to fork session",
"sessionView.alerts.forkFailed.title": "Fork failed", "sessionView.alerts.forkFailed.title": "Fork failed",
"sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text", "sessionView.attachments.expandPastedTextAriaLabel": "Expand pasted text",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff view mode", "toolCall.diff.viewMode.ariaLabel": "Diff view mode",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos", "commands.diagnosticsDefault.description": "Alternar la expansión por defecto de la salida de diagnósticos",
"commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar", "commands.diagnosticsDefault.keywords": "diagnósticos, expandir, colapsar",
"commands.toolInputsVisibility.label": "Visibilidad de entradas de herramientas · {state}",
"commands.toolInputsVisibility.description": "Configurar la visibilidad por defecto de los argumentos de entrada de llamadas de herramienta",
"commands.toolInputsVisibility.keywords": "herramienta, entradas, argumentos, visibilidad, ocultar, mostrar, expandir, colapsar",
"commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}", "commands.tokenUsageDisplay.label": "Mostrar uso de tokens · {state}",
"commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente", "commands.tokenUsageDisplay.description": "Mostrar u ocultar estadísticas de tokens y costo en los mensajes del asistente",
"commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas", "commands.tokenUsageDisplay.keywords": "token, uso, costo, estadísticas",

View File

@@ -39,13 +39,16 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Abrir panel derecho", "instanceShell.rightDrawer.toggle.open": "Abrir panel derecho",
"instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho", "instanceShell.rightDrawer.toggle.close": "Cerrar panel derecho",
"instanceShell.fullscreen.enter": "Pantalla completa",
"instanceShell.fullscreen.exit": "Salir de pantalla completa",
"instanceShell.metrics.usedLabel": "Usado", "instanceShell.metrics.usedLabel": "Usado",
"instanceShell.metrics.availableLabel": "Disp.", "instanceShell.metrics.availableLabel": "Disp.",
"instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos", "instanceShell.commandPalette.openAriaLabel": "Abrir paleta de comandos",
"instanceShell.commandPalette.button": "Paleta de comandos", "instanceShell.commandPalette.button": "Paleta de comandos",
"instanceShell.connection.ariaLabel": "Connection {status}", "instanceShell.connection.ariaLabel": "Conexión {status}",
"instanceShell.connection.connected": "Conectada", "instanceShell.connection.connected": "Conectada",
"instanceShell.connection.connecting": "Conectando...", "instanceShell.connection.connecting": "Conectando...",
"instanceShell.connection.disconnected": "Desconectada", "instanceShell.connection.disconnected": "Desconectada",
@@ -90,16 +93,22 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.files": "Archivos", "instanceShell.rightPanel.tabs.files": "Archivos",
"instanceShell.rightPanel.tabs.status": "Estado", "instanceShell.rightPanel.tabs.status": "Estado",
"instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho", "instanceShell.rightPanel.tabs.ariaLabel": "Pestañas del panel derecho",
"instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesion", "instanceShell.rightPanel.sections.sessionChanges": "Cambios de sesión",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Archivos modificados en la sesión actual. Muestra las adiciones y eliminaciones de cada archivo.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Hoja de ruta del agente para esta sesión. Realiza el seguimiento de tareas, subtareas y su estado de finalización.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en segundo plano",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Procesos de larga duración iniciados por el agente. Puedes supervisar su salida, detenerlos o terminarlos.",
"instanceShell.rightPanel.sections.mcp": "Servidores MCP", "instanceShell.rightPanel.sections.mcp": "Servidores MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Servidores del Model Context Protocol (MCP) que amplían las capacidades del agente con herramientas y servicios externos.",
"instanceShell.rightPanel.sections.lsp": "Servidores LSP", "instanceShell.rightPanel.sections.lsp": "Servidores LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Servidores del Language Server Protocol (LSP) que proporcionan inteligencia de código, diagnósticos y funciones específicas del lenguaje.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins que personalizan el comportamiento de la UI y del servidor, y añaden funciones más allá de MCP y LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesion para ver los cambios.", "instanceShell.sessionChanges.noSessionSelected": "Selecciona una sesión para ver los cambios.",
"instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesion...", "instanceShell.sessionChanges.loading": "Obteniendo cambios de la sesión...",
"instanceShell.sessionChanges.empty": "Aun no hay cambios.", "instanceShell.sessionChanges.empty": "Aún no hay cambios.",
"instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados", "instanceShell.sessionChanges.filesChanged": "{count} archivos cambiados",
"instanceShell.sessionChanges.actions.show": "Mostrar cambios", "instanceShell.sessionChanges.actions.show": "Mostrar cambios",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.", "infoView.logs.paused.description": "Activa el streaming para ver la actividad de tu servidor de OpenCode.",
"infoView.logs.empty.waiting": "Esperando la salida del servidor...", "infoView.logs.empty.waiting": "Esperando la salida del servidor...",
"infoView.logs.scrollToBottom": "Desplazarse al final", "infoView.logs.scrollToBottom": "Desplazarse al final",
"infoView.dispose.actions.dispose": "Desechar instancia",
"infoView.dispose.actions.disposing": "Desechando...",
"infoView.dispose.confirm.title": "¿Desechar instancia?",
"infoView.dispose.confirm.message": "Esto borra el estado en caché por proyecto para este directorio y recarga la instancia.",
"infoView.dispose.confirm.confirmLabel": "Desechar",
"infoView.dispose.confirm.cancelLabel": "Cancelar",
"infoView.dispose.toast.success": "Instancia desechada. Recargando...",
"infoView.dispose.toast.error": "No se pudo desechar la instancia.",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Ir a sesión", "messageBlock.tool.goToSession.label": "Ir a sesión",
"messageBlock.tool.goToSession.title": "Ir a la sesión", "messageBlock.tool.goToSession.title": "Ir a la sesión",
"messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible", "messageBlock.tool.goToSession.unavailableTitle": "La sesión aún no está disponible",
"messageBlock.tool.deletePart.label": "Eliminar", "messageBlock.tool.deletePart.label": "Eliminar parte",
"messageBlock.tool.deletePart.deleting": "Eliminando...", "messageBlock.tool.deletePart.deleting": "Eliminando...",
"messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta", "messageBlock.tool.deletePart.title": "Eliminar esta salida de herramienta",
"messageBlock.tool.deletePart.failed.title": "Error al eliminar", "messageBlock.tool.deletePart.failed.title": "Error al eliminar",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "Tú", "messageItem.speaker.you": "Tú",
"messageItem.speaker.assistant": "Asistente", "messageItem.speaker.assistant": "Asistente",
"messageItem.actions.revert": "Revertir", "messageItem.actions.revert": "Revertir",
"messageItem.actions.revertTitle": "Revertir a este mensaje", "messageItem.actions.revertTitle": "Deshacer cambios hasta aqui (elimina mensajes)",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork desde este mensaje", "messageItem.actions.forkTitle": "Fork desde este mensaje",
"messageItem.actions.copy": "Copiar", "messageItem.actions.copy": "Copiar",
"messageItem.actions.copyTitle": "Copiar mensaje", "messageItem.actions.copyTitle": "Copiar mensaje",
"messageItem.actions.copied": "¡Copiado!", "messageItem.actions.copied": "¡Copiado!",
"messageItem.actions.deleteMessage": "Eliminar mensaje (no deshace cambios)",
"messageItem.actions.deleteMessagesUpTo": "Eliminar mensajes hasta aqui (no deshace cambios)",
"messageItem.actions.deletingMessage": "Eliminando...",
"messageItem.actions.deleteMessageFailedTitle": "Error al eliminar",
"messageItem.actions.deleteMessageFailedMessage": "No se pudo eliminar el mensaje",
"messageItem.selection.checkboxAriaLabel": "Seleccionar mensaje para eliminar",
"messageSection.bulkDelete.toolbarAriaLabel": "Mensajes seleccionados ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Eliminar mensajes seleccionados",
"messageSection.bulkDelete.selectAllTitle": "Seleccionar todos los mensajes",
"messageSection.bulkDelete.cancelTitle": "Cancelar selección",
"messageSection.bulkDelete.failedTitle": "Error al eliminar",
"messageSection.bulkDelete.failedMessage": "No se pudieron eliminar los mensajes seleccionados",
"messageItem.status.queued": "EN COLA", "messageItem.status.queued": "EN COLA",
"messageItem.status.generating": "Generando...", "messageItem.status.generating": "Generando...",
"messageItem.status.sending": "Enviando...", "messageItem.status.sending": "Enviando...",
"messageItem.status.failedToSend": "No se pudo enviar el mensaje", "messageItem.status.failedToSend": "No se pudo enviar el mensaje",
"messagePart.actions.delete": "Eliminar", "messagePart.actions.delete": "Eliminar parte",
"messagePart.actions.deleting": "Eliminando...", "messagePart.actions.deleting": "Eliminando...",
"messagePart.actions.deleteTitle": "Eliminar este elemento", "messagePart.actions.deleteTitle": "Eliminar este elemento",
"messagePart.actions.deleteFailedTitle": "Error al eliminar", "messagePart.actions.deleteFailedTitle": "Error al eliminar",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "No se pudo detener", "sessionView.alerts.abortFailed.title": "No se pudo detener",
"sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje", "sessionView.alerts.revertFailed.message": "No se pudo revertir al mensaje",
"sessionView.alerts.revertFailed.title": "No se pudo revertir", "sessionView.alerts.revertFailed.title": "No se pudo revertir",
"sessionView.alerts.deleteUpToFailed.message": "No se pudieron eliminar los mensajes",
"sessionView.alerts.deleteUpToFailed.title": "Error al eliminar",
"sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión", "sessionView.alerts.forkFailed.message": "No se pudo hacer fork de la sesión",
"sessionView.alerts.forkFailed.title": "No se pudo hacer fork", "sessionView.alerts.forkFailed.title": "No se pudo hacer fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado", "sessionView.attachments.expandPastedTextAriaLabel": "Expandir texto pegado",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff", "toolCall.diff.viewMode.ariaLabel": "Modo de vista de diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics", "commands.diagnosticsDefault.description": "Choisir l'ouverture par défaut de la sortie des diagnostics",
"commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire", "commands.diagnosticsDefault.keywords": "diagnostics, développer, réduire",
"commands.toolInputsVisibility.label": "Visibilité des entrées d'outil · {state}",
"commands.toolInputsVisibility.description": "Définir la visibilité par défaut des arguments d'entrée des appels d'outil",
"commands.toolInputsVisibility.keywords": "outil, entrées, arguments, visibilité, masquer, afficher, développer, réduire",
"commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}", "commands.tokenUsageDisplay.label": "Affichage de l'usage des tokens · {state}",
"commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant", "commands.tokenUsageDisplay.description": "Afficher ou masquer les stats de tokens et de coût pour les messages de l'assistant",
"commands.tokenUsageDisplay.keywords": "token, usage, coût, stats", "commands.tokenUsageDisplay.keywords": "token, usage, coût, stats",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit", "instanceShell.rightDrawer.toggle.open": "Ouvrir le tiroir droit",
"instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit", "instanceShell.rightDrawer.toggle.close": "Fermer le tiroir droit",
"instanceShell.fullscreen.enter": "Plein écran",
"instanceShell.fullscreen.exit": "Quitter le plein écran",
"instanceShell.metrics.usedLabel": "Utilisé", "instanceShell.metrics.usedLabel": "Utilisé",
"instanceShell.metrics.availableLabel": "Dispo", "instanceShell.metrics.availableLabel": "Dispo",
@@ -91,11 +94,17 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Statut", "instanceShell.rightPanel.tabs.status": "Statut",
"instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit", "instanceShell.rightPanel.tabs.ariaLabel": "Onglets du panneau droit",
"instanceShell.rightPanel.sections.sessionChanges": "Changements de session", "instanceShell.rightPanel.sections.sessionChanges": "Changements de session",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Fichiers modifiés dans la session actuelle. Affiche les ajouts et suppressions pour chaque fichier.",
"instanceShell.rightPanel.sections.plan": "Plan", "instanceShell.rightPanel.sections.plan": "Plan",
"instanceShell.rightPanel.sections.plan.tooltip": "Feuille de route de l'agent pour cette session. Suit les tâches et leur statut d'achèvement.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan", "instanceShell.rightPanel.sections.backgroundProcesses": "Shells en arrière-plan",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Processus longs démarrés par l'agent. Vous pouvez surveiller leur sortie, les arrêter ou les terminer.",
"instanceShell.rightPanel.sections.mcp": "Serveurs MCP", "instanceShell.rightPanel.sections.mcp": "Serveurs MCP",
"instanceShell.rightPanel.sections.mcp.tooltip": "Serveurs du protocole Model Context Protocol qui étendent les capacités de l'agent avec des outils externes.",
"instanceShell.rightPanel.sections.lsp": "Serveurs LSP", "instanceShell.rightPanel.sections.lsp": "Serveurs LSP",
"instanceShell.rightPanel.sections.lsp.tooltip": "Serveurs du protocole Language Server Protocol fournissant l'intelligence de code et les diagnostics.",
"instanceShell.rightPanel.sections.plugins": "Plugins", "instanceShell.rightPanel.sections.plugins": "Plugins",
"instanceShell.rightPanel.sections.plugins.tooltip": "Plugins qui personnalisent le comportement de l'UI et du serveur, ajoutant des fonctionnalités au-delà de MCP et LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.", "instanceShell.sessionChanges.noSessionSelected": "Sélectionnez une session pour voir les changements.",
"instanceShell.sessionChanges.loading": "Récupération des changements...", "instanceShell.sessionChanges.loading": "Récupération des changements...",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.", "infoView.logs.paused.description": "Activez le streaming pour suivre l'activité de votre serveur OpenCode.",
"infoView.logs.empty.waiting": "En attente de la sortie du serveur...", "infoView.logs.empty.waiting": "En attente de la sortie du serveur...",
"infoView.logs.scrollToBottom": "Aller en bas", "infoView.logs.scrollToBottom": "Aller en bas",
"infoView.dispose.actions.dispose": "Réinitialiser l'instance",
"infoView.dispose.actions.disposing": "Réinitialisation...",
"infoView.dispose.confirm.title": "Réinitialiser l'instance ?",
"infoView.dispose.confirm.message": "Cela efface l'état en cache pour ce répertoire et recharge l'instance.",
"infoView.dispose.confirm.confirmLabel": "Réinitialiser",
"infoView.dispose.confirm.cancelLabel": "Annuler",
"infoView.dispose.toast.success": "Instance réinitialisée. Rechargement...",
"infoView.dispose.toast.error": "Impossible de réinitialiser l'instance.",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Aller à la session", "messageBlock.tool.goToSession.label": "Aller à la session",
"messageBlock.tool.goToSession.title": "Aller à la session", "messageBlock.tool.goToSession.title": "Aller à la session",
"messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible", "messageBlock.tool.goToSession.unavailableTitle": "Session pas encore disponible",
"messageBlock.tool.deletePart.label": "Supprimer", "messageBlock.tool.deletePart.label": "Supprimer la partie",
"messageBlock.tool.deletePart.deleting": "Suppression...", "messageBlock.tool.deletePart.deleting": "Suppression...",
"messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil", "messageBlock.tool.deletePart.title": "Supprimer cette sortie d'outil",
"messageBlock.tool.deletePart.failed.title": "Échec de suppression", "messageBlock.tool.deletePart.failed.title": "Échec de suppression",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "Vous", "messageItem.speaker.you": "Vous",
"messageItem.speaker.assistant": "Assistant", "messageItem.speaker.assistant": "Assistant",
"messageItem.actions.revert": "Revenir", "messageItem.actions.revert": "Revenir",
"messageItem.actions.revertTitle": "Revenir à ce message", "messageItem.actions.revertTitle": "Annuler les changements jusqu'ici (supprime les messages)",
"messageItem.actions.fork": "Fork", "messageItem.actions.fork": "Fork",
"messageItem.actions.forkTitle": "Fork depuis ce message", "messageItem.actions.forkTitle": "Fork depuis ce message",
"messageItem.actions.copy": "Copier", "messageItem.actions.copy": "Copier",
"messageItem.actions.copyTitle": "Copier le message", "messageItem.actions.copyTitle": "Copier le message",
"messageItem.actions.copied": "Copié !", "messageItem.actions.copied": "Copié !",
"messageItem.actions.deleteMessage": "Supprimer le message (sans annuler les changements)",
"messageItem.actions.deleteMessagesUpTo": "Supprimer les messages jusqu'ici (sans annuler les changements)",
"messageItem.actions.deletingMessage": "Suppression...",
"messageItem.actions.deleteMessageFailedTitle": "Échec de suppression",
"messageItem.actions.deleteMessageFailedMessage": "Impossible de supprimer le message",
"messageItem.selection.checkboxAriaLabel": "Sélectionner le message pour suppression",
"messageSection.bulkDelete.toolbarAriaLabel": "Messages sélectionnés ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Supprimer les messages sélectionnés",
"messageSection.bulkDelete.selectAllTitle": "Tout sélectionner",
"messageSection.bulkDelete.cancelTitle": "Annuler la sélection",
"messageSection.bulkDelete.failedTitle": "Échec de suppression",
"messageSection.bulkDelete.failedMessage": "Impossible de supprimer les messages sélectionnés",
"messageItem.status.queued": "EN FILE", "messageItem.status.queued": "EN FILE",
"messageItem.status.generating": "Génération...", "messageItem.status.generating": "Génération...",
"messageItem.status.sending": "Envoi...", "messageItem.status.sending": "Envoi...",
"messageItem.status.failedToSend": "Échec de l'envoi du message", "messageItem.status.failedToSend": "Échec de l'envoi du message",
"messagePart.actions.delete": "Supprimer", "messagePart.actions.delete": "Supprimer la partie",
"messagePart.actions.deleting": "Suppression...", "messagePart.actions.deleting": "Suppression...",
"messagePart.actions.deleteTitle": "Supprimer cet élément", "messagePart.actions.deleteTitle": "Supprimer cet élément",
"messagePart.actions.deleteFailedTitle": "Échec de suppression", "messagePart.actions.deleteFailedTitle": "Échec de suppression",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Échec de l'arrêt", "sessionView.alerts.abortFailed.title": "Échec de l'arrêt",
"sessionView.alerts.revertFailed.message": "Impossible de revenir au message", "sessionView.alerts.revertFailed.message": "Impossible de revenir au message",
"sessionView.alerts.revertFailed.title": "Échec du retour", "sessionView.alerts.revertFailed.title": "Échec du retour",
"sessionView.alerts.deleteUpToFailed.message": "Impossible de supprimer les messages",
"sessionView.alerts.deleteUpToFailed.title": "Échec de suppression",
"sessionView.alerts.forkFailed.message": "Impossible de forker la session", "sessionView.alerts.forkFailed.message": "Impossible de forker la session",
"sessionView.alerts.forkFailed.title": "Échec du fork", "sessionView.alerts.forkFailed.title": "Échec du fork",
"sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé", "sessionView.attachments.expandPastedTextAriaLabel": "Développer le texte collé",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff", "toolCall.diff.viewMode.ariaLabel": "Mode d'affichage du diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え", "commands.diagnosticsDefault.description": "診断出力を既定で展開するか切り替え",
"commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse", "commands.diagnosticsDefault.keywords": "診断, 展開, 折りたたみ, diagnostics, expand, collapse",
"commands.toolInputsVisibility.label": "ツール入力の表示 · {state}",
"commands.toolInputsVisibility.description": "ツール呼び出しの入力引数の既定の表示状態を設定します",
"commands.toolInputsVisibility.keywords": "ツール, 入力, 引数, 表示, 非表示, 展開, 折りたたみ, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}", "commands.tokenUsageDisplay.label": "トークン使用量表示 · {state}",
"commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示", "commands.tokenUsageDisplay.description": "アシスタントメッセージのトークン/コスト統計を表示/非表示",
"commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats", "commands.tokenUsageDisplay.keywords": "トークン, 使用量, コスト, 統計, token, usage, cost, stats",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "右ドロワーを開く", "instanceShell.rightDrawer.toggle.open": "右ドロワーを開く",
"instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる", "instanceShell.rightDrawer.toggle.close": "右ドロワーを閉じる",
"instanceShell.fullscreen.enter": "全画面",
"instanceShell.fullscreen.exit": "全画面を終了",
"instanceShell.metrics.usedLabel": "使用", "instanceShell.metrics.usedLabel": "使用",
"instanceShell.metrics.availableLabel": "残り", "instanceShell.metrics.availableLabel": "残り",
@@ -91,11 +94,17 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "ステータス", "instanceShell.rightPanel.tabs.status": "ステータス",
"instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ", "instanceShell.rightPanel.tabs.ariaLabel": "右パネルのタブ",
"instanceShell.rightPanel.sections.sessionChanges": "セッション変更", "instanceShell.rightPanel.sections.sessionChanges": "セッション変更",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "現在のセッションで変更されたファイル。各ファイルの追加と削除を表示します。",
"instanceShell.rightPanel.sections.plan": "計画", "instanceShell.rightPanel.sections.plan": "計画",
"instanceShell.rightPanel.sections.plan.tooltip": "このセッションにおけるエージェントのロードマップ。タスクやサブタスク、および完了状況を追跡します。",
"instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル", "instanceShell.rightPanel.sections.backgroundProcesses": "バックグラウンドシェル",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "エージェントが開始した長時間実行プロセス。出力を監視し、停止または終了できます。",
"instanceShell.rightPanel.sections.mcp": "MCP サーバー", "instanceShell.rightPanel.sections.mcp": "MCP サーバー",
"instanceShell.rightPanel.sections.mcp.tooltip": "Model Context Protocol (MCP) サーバー。外部ツールやサービスでエージェントの機能を拡張します。",
"instanceShell.rightPanel.sections.lsp": "LSP サーバー", "instanceShell.rightPanel.sections.lsp": "LSP サーバー",
"instanceShell.rightPanel.sections.lsp.tooltip": "Language Server Protocolサーバーがコードインテリジェンス、診断、言語固有の機能を提供します。",
"instanceShell.rightPanel.sections.plugins": "プラグイン", "instanceShell.rightPanel.sections.plugins": "プラグイン",
"instanceShell.rightPanel.sections.plugins.tooltip": "UI とサーバーの動作をカスタマイズし、MCP や LSP 以外の機能も追加できるプラグイン。",
"instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。", "instanceShell.sessionChanges.noSessionSelected": "変更を表示するにはセッションを選択してください。",
"instanceShell.sessionChanges.loading": "変更を取得中...", "instanceShell.sessionChanges.loading": "変更を取得中...",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。", "infoView.logs.paused.description": "ストリーミングを有効にして OpenCode サーバーの動作を監視します。",
"infoView.logs.empty.waiting": "サーバー出力を待機中...", "infoView.logs.empty.waiting": "サーバー出力を待機中...",
"infoView.logs.scrollToBottom": "最下部へスクロール", "infoView.logs.scrollToBottom": "最下部へスクロール",
"infoView.dispose.actions.dispose": "インスタンスを破棄",
"infoView.dispose.actions.disposing": "破棄しています...",
"infoView.dispose.confirm.title": "インスタンスを破棄しますか?",
"infoView.dispose.confirm.message": "このディレクトリのプロジェクト状態キャッシュをクリアし、インスタンスを再読み込みします。",
"infoView.dispose.confirm.confirmLabel": "破棄",
"infoView.dispose.confirm.cancelLabel": "キャンセル",
"infoView.dispose.toast.success": "インスタンスを破棄しました。再読み込み中...",
"infoView.dispose.toast.error": "インスタンスの破棄に失敗しました。",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "セッションへ移動", "messageBlock.tool.goToSession.label": "セッションへ移動",
"messageBlock.tool.goToSession.title": "セッションへ移動", "messageBlock.tool.goToSession.title": "セッションへ移動",
"messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません", "messageBlock.tool.goToSession.unavailableTitle": "セッションはまだ利用できません",
"messageBlock.tool.deletePart.label": "削除", "messageBlock.tool.deletePart.label": "パートを削除",
"messageBlock.tool.deletePart.deleting": "削除中...", "messageBlock.tool.deletePart.deleting": "削除中...",
"messageBlock.tool.deletePart.title": "このツール出力を削除", "messageBlock.tool.deletePart.title": "このツール出力を削除",
"messageBlock.tool.deletePart.failed.title": "削除に失敗しました", "messageBlock.tool.deletePart.failed.title": "削除に失敗しました",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "あなた", "messageItem.speaker.you": "あなた",
"messageItem.speaker.assistant": "アシスタント", "messageItem.speaker.assistant": "アシスタント",
"messageItem.actions.revert": "戻す", "messageItem.actions.revert": "戻す",
"messageItem.actions.revertTitle": "こメッセージまで戻す", "messageItem.actions.revertTitle": "ここまでの変更を元に戻す(メッセージを削除)",
"messageItem.actions.fork": "フォーク", "messageItem.actions.fork": "フォーク",
"messageItem.actions.forkTitle": "このメッセージからフォーク", "messageItem.actions.forkTitle": "このメッセージからフォーク",
"messageItem.actions.copy": "コピー", "messageItem.actions.copy": "コピー",
"messageItem.actions.copyTitle": "メッセージをコピー", "messageItem.actions.copyTitle": "メッセージをコピー",
"messageItem.actions.copied": "コピーしました!", "messageItem.actions.copied": "コピーしました!",
"messageItem.actions.deleteMessage": "メッセージを削除(変更は元に戻さない)",
"messageItem.actions.deleteMessagesUpTo": "ここまでのメッセージを削除(変更は元に戻さない)",
"messageItem.actions.deletingMessage": "削除中...",
"messageItem.actions.deleteMessageFailedTitle": "削除に失敗しました",
"messageItem.actions.deleteMessageFailedMessage": "メッセージの削除に失敗しました",
"messageItem.selection.checkboxAriaLabel": "削除するメッセージを選択",
"messageSection.bulkDelete.toolbarAriaLabel": "選択したメッセージ({count}",
"messageSection.bulkDelete.deleteSelectedTitle": "選択したメッセージを削除",
"messageSection.bulkDelete.selectAllTitle": "すべて選択",
"messageSection.bulkDelete.cancelTitle": "選択をキャンセル",
"messageSection.bulkDelete.failedTitle": "削除に失敗しました",
"messageSection.bulkDelete.failedMessage": "選択したメッセージの削除に失敗しました",
"messageItem.status.queued": "待機中", "messageItem.status.queued": "待機中",
"messageItem.status.generating": "生成中...", "messageItem.status.generating": "生成中...",
"messageItem.status.sending": "送信中...", "messageItem.status.sending": "送信中...",
"messageItem.status.failedToSend": "メッセージの送信に失敗しました", "messageItem.status.failedToSend": "メッセージの送信に失敗しました",
"messagePart.actions.delete": "削除", "messagePart.actions.delete": "パートを削除",
"messagePart.actions.deleting": "削除中...", "messagePart.actions.deleting": "削除中...",
"messagePart.actions.deleteTitle": "この項目を削除", "messagePart.actions.deleteTitle": "この項目を削除",
"messagePart.actions.deleteFailedTitle": "削除に失敗しました", "messagePart.actions.deleteFailedTitle": "削除に失敗しました",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "停止に失敗", "sessionView.alerts.abortFailed.title": "停止に失敗",
"sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした", "sessionView.alerts.revertFailed.message": "メッセージへ戻せませんでした",
"sessionView.alerts.revertFailed.title": "復元に失敗", "sessionView.alerts.revertFailed.title": "復元に失敗",
"sessionView.alerts.deleteUpToFailed.message": "メッセージの削除に失敗しました",
"sessionView.alerts.deleteUpToFailed.title": "削除に失敗しました",
"sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました", "sessionView.alerts.forkFailed.message": "セッションのフォークに失敗しました",
"sessionView.alerts.forkFailed.title": "フォークに失敗", "sessionView.alerts.forkFailed.title": "フォークに失敗",
"sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開", "sessionView.attachments.expandPastedTextAriaLabel": "貼り付けたテキストを展開",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "diff 表示モード", "toolCall.diff.viewMode.ariaLabel": "diff 表示モード",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию", "commands.diagnosticsDefault.description": "Переключить, разворачивать ли вывод диагностики по умолчанию",
"commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть", "commands.diagnosticsDefault.keywords": "diagnostics, развернуть, свернуть",
"commands.toolInputsVisibility.label": "Видимость входных данных инструмента · {state}",
"commands.toolInputsVisibility.description": "Установить видимость аргументов входа вызовов инструментов по умолчанию",
"commands.toolInputsVisibility.keywords": "инструмент, вход, аргументы, видимость, скрыть, показать, раскрыть, свернуть, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}", "commands.tokenUsageDisplay.label": "Отображение token-статистики · {state}",
"commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента", "commands.tokenUsageDisplay.description": "Показать или скрыть статистику token и стоимости для сообщений ассистента",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика", "commands.tokenUsageDisplay.keywords": "token, usage, cost, статистика",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "Открыть правую панель", "instanceShell.rightDrawer.toggle.open": "Открыть правую панель",
"instanceShell.rightDrawer.toggle.close": "Закрыть правую панель", "instanceShell.rightDrawer.toggle.close": "Закрыть правую панель",
"instanceShell.fullscreen.enter": "Полный экран",
"instanceShell.fullscreen.exit": "Выйти из полного экрана",
"instanceShell.metrics.usedLabel": "Использовано", "instanceShell.metrics.usedLabel": "Использовано",
"instanceShell.metrics.availableLabel": "Доступно", "instanceShell.metrics.availableLabel": "Доступно",
@@ -91,11 +94,17 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "Статус", "instanceShell.rightPanel.tabs.status": "Статус",
"instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели", "instanceShell.rightPanel.tabs.ariaLabel": "Вкладки правой панели",
"instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии", "instanceShell.rightPanel.sections.sessionChanges": "Изменения сессии",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "Файлы, измененные в текущей сессии. Показывает добавления и удаления для каждого файла.",
"instanceShell.rightPanel.sections.plan": "План", "instanceShell.rightPanel.sections.plan": "План",
"instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые Shell", "instanceShell.rightPanel.sections.plan.tooltip": "Дорожная карта агента для этой сессии. Отслеживает задачи и их статус выполнения.",
"instanceShell.rightPanel.sections.backgroundProcesses": "Фоновые оболочки",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "Долгоработающие процессы, запущенные агентом. Вы можете следить за их выводом, останавливать или завершать их.",
"instanceShell.rightPanel.sections.mcp": "MCP-серверы", "instanceShell.rightPanel.sections.mcp": "MCP-серверы",
"instanceShell.rightPanel.sections.mcp.tooltip": "Серверы протокола Model Context Protocol, расширяющие возможности агента внешними инструментами.",
"instanceShell.rightPanel.sections.lsp": "LSP-серверы", "instanceShell.rightPanel.sections.lsp": "LSP-серверы",
"instanceShell.rightPanel.sections.lsp.tooltip": "Серверы протокола Language Server Protocol, обеспечивающие интеллектуальную поддержку кода и диагностику.",
"instanceShell.rightPanel.sections.plugins": "Плагины", "instanceShell.rightPanel.sections.plugins": "Плагины",
"instanceShell.rightPanel.sections.plugins.tooltip": "Плагины, настраивающие поведение интерфейса и сервера, добавляющие функции поверх MCP и LSP.",
"instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.", "instanceShell.sessionChanges.noSessionSelected": "Выберите сессию, чтобы просмотреть изменения.",
"instanceShell.sessionChanges.loading": "Загрузка изменений...", "instanceShell.sessionChanges.loading": "Загрузка изменений...",
@@ -125,7 +134,7 @@ export const instanceMessages = {
"versionPill.uiWithVersion": "UI {version}", "versionPill.uiWithVersion": "UI {version}",
"versionPill.source": " ({source})", "versionPill.source": " ({source})",
"opencodeBinarySelector.title": "OpenCode Binary", "opencodeBinarySelector.title": "Бинарник OpenCode",
"opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать", "opencodeBinarySelector.subtitle": "Выберите, какой исполняемый файл OpenCode запускать",
"opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…", "opencodeBinarySelector.customPath.placeholder": "Введите путь к бинарнику opencode…",
"opencodeBinarySelector.actions.add": "Добавить", "opencodeBinarySelector.actions.add": "Добавить",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.", "infoView.logs.paused.description": "Включите стриминг, чтобы наблюдать за активностью сервера OpenCode.",
"infoView.logs.empty.waiting": "Ожидание вывода сервера…", "infoView.logs.empty.waiting": "Ожидание вывода сервера…",
"infoView.logs.scrollToBottom": "Прокрутить вниз", "infoView.logs.scrollToBottom": "Прокрутить вниз",
"infoView.dispose.actions.dispose": "Сбросить инстанс",
"infoView.dispose.actions.disposing": "Сброс...",
"infoView.dispose.confirm.title": "Сбросить инстанс?",
"infoView.dispose.confirm.message": "Это очистит кэш состояния проекта для этого каталога и перезагрузит инстанс.",
"infoView.dispose.confirm.confirmLabel": "Сбросить",
"infoView.dispose.confirm.cancelLabel": "Отмена",
"infoView.dispose.toast.success": "Инстанс сброшен. Перезагрузка...",
"infoView.dispose.toast.error": "Не удалось сбросить инстанс.",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "Перейти к сессии", "messageBlock.tool.goToSession.label": "Перейти к сессии",
"messageBlock.tool.goToSession.title": "Перейти к сессии", "messageBlock.tool.goToSession.title": "Перейти к сессии",
"messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна", "messageBlock.tool.goToSession.unavailableTitle": "Сессия пока недоступна",
"messageBlock.tool.deletePart.label": "Удалить", "messageBlock.tool.deletePart.label": "Удалить часть",
"messageBlock.tool.deletePart.deleting": "Удаление...", "messageBlock.tool.deletePart.deleting": "Удаление...",
"messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента", "messageBlock.tool.deletePart.title": "Удалить этот вывод инструмента",
"messageBlock.tool.deletePart.failed.title": "Ошибка удаления", "messageBlock.tool.deletePart.failed.title": "Ошибка удаления",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "Вы", "messageItem.speaker.you": "Вы",
"messageItem.speaker.assistant": "Ассистент", "messageItem.speaker.assistant": "Ассистент",
"messageItem.actions.revert": "Откатить", "messageItem.actions.revert": "Откатить",
"messageItem.actions.revertTitle": "Откатиться к этому сообщению", "messageItem.actions.revertTitle": "Отменить изменения до этого места (удалит сообщения)",
"messageItem.actions.fork": "Форк", "messageItem.actions.fork": "Форк",
"messageItem.actions.forkTitle": "Форкнуть от этого сообщения", "messageItem.actions.forkTitle": "Форкнуть от этого сообщения",
"messageItem.actions.copy": "Копировать", "messageItem.actions.copy": "Копировать",
"messageItem.actions.copyTitle": "Копировать сообщение", "messageItem.actions.copyTitle": "Копировать сообщение",
"messageItem.actions.copied": "Скопировано!", "messageItem.actions.copied": "Скопировано!",
"messageItem.actions.deleteMessage": "Удалить сообщение (без отката изменений)",
"messageItem.actions.deleteMessagesUpTo": "Удалить сообщения до этого места (без отката изменений)",
"messageItem.actions.deletingMessage": "Удаление...",
"messageItem.actions.deleteMessageFailedTitle": "Ошибка удаления",
"messageItem.actions.deleteMessageFailedMessage": "Не удалось удалить сообщение",
"messageItem.selection.checkboxAriaLabel": "Выбрать сообщение для удаления",
"messageSection.bulkDelete.toolbarAriaLabel": "Выбранные сообщения ({count})",
"messageSection.bulkDelete.deleteSelectedTitle": "Удалить выбранные сообщения",
"messageSection.bulkDelete.selectAllTitle": "Выбрать все сообщения",
"messageSection.bulkDelete.cancelTitle": "Отменить выбор",
"messageSection.bulkDelete.failedTitle": "Ошибка удаления",
"messageSection.bulkDelete.failedMessage": "Не удалось удалить выбранные сообщения",
"messageItem.status.queued": "В ОЧЕРЕДИ", "messageItem.status.queued": "В ОЧЕРЕДИ",
"messageItem.status.generating": "Генерация…", "messageItem.status.generating": "Генерация…",
"messageItem.status.sending": "Отправка…", "messageItem.status.sending": "Отправка…",
"messageItem.status.failedToSend": "Не удалось отправить сообщение", "messageItem.status.failedToSend": "Не удалось отправить сообщение",
"messagePart.actions.delete": "Удалить", "messagePart.actions.delete": "Удалить часть",
"messagePart.actions.deleting": "Удаление...", "messagePart.actions.deleting": "Удаление...",
"messagePart.actions.deleteTitle": "Удалить этот элемент", "messagePart.actions.deleteTitle": "Удалить этот элемент",
"messagePart.actions.deleteFailedTitle": "Ошибка удаления", "messagePart.actions.deleteFailedTitle": "Ошибка удаления",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "Не удалось остановить", "sessionView.alerts.abortFailed.title": "Не удалось остановить",
"sessionView.alerts.revertFailed.message": "Не удалось откатиться к сообщению", "sessionView.alerts.revertFailed.message": "Не удалось откатиться к сообщению",
"sessionView.alerts.revertFailed.title": "Не удалось откатиться", "sessionView.alerts.revertFailed.title": "Не удалось откатиться",
"sessionView.alerts.deleteUpToFailed.message": "Не удалось удалить сообщения",
"sessionView.alerts.deleteUpToFailed.title": "Ошибка удаления",
"sessionView.alerts.forkFailed.message": "Не удалось форкнуть сессию", "sessionView.alerts.forkFailed.message": "Не удалось форкнуть сессию",
"sessionView.alerts.forkFailed.title": "Не удалось форкнуть", "sessionView.alerts.forkFailed.title": "Не удалось форкнуть",
"sessionView.attachments.expandPastedTextAriaLabel": "Развернуть вставленный текст", "sessionView.attachments.expandPastedTextAriaLabel": "Развернуть вставленный текст",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff", "toolCall.diff.viewMode.ariaLabel": "Режим просмотра diff",

View File

@@ -130,6 +130,10 @@ export const commandMessages = {
"commands.diagnosticsDefault.description": "切换诊断输出是否默认展开", "commands.diagnosticsDefault.description": "切换诊断输出是否默认展开",
"commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠", "commands.diagnosticsDefault.keywords": "diagnostics, expand, collapse, 诊断, 展开, 折叠",
"commands.toolInputsVisibility.label": "工具输入可见性 · {state}",
"commands.toolInputsVisibility.description": "设置工具调用输入参数的默认可见性",
"commands.toolInputsVisibility.keywords": "工具, 输入, 参数, 可见性, 隐藏, 显示, 展开, 折叠, tool, inputs, arguments, visibility, hide, show, expand, collapse",
"commands.tokenUsageDisplay.label": "Token 使用显示 · {state}", "commands.tokenUsageDisplay.label": "Token 使用显示 · {state}",
"commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计", "commands.tokenUsageDisplay.description": "显示或隐藏助手消息的 token 和费用统计",
"commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计", "commands.tokenUsageDisplay.keywords": "token, usage, cost, stats, 令牌, 用量, 费用, 统计",

View File

@@ -39,6 +39,9 @@ export const instanceMessages = {
"instanceShell.rightDrawer.toggle.open": "打开右侧抽屉", "instanceShell.rightDrawer.toggle.open": "打开右侧抽屉",
"instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉", "instanceShell.rightDrawer.toggle.close": "关闭右侧抽屉",
"instanceShell.fullscreen.enter": "全屏",
"instanceShell.fullscreen.exit": "退出全屏",
"instanceShell.metrics.usedLabel": "已用", "instanceShell.metrics.usedLabel": "已用",
"instanceShell.metrics.availableLabel": "可用", "instanceShell.metrics.availableLabel": "可用",
@@ -91,11 +94,17 @@ export const instanceMessages = {
"instanceShell.rightPanel.tabs.status": "状态", "instanceShell.rightPanel.tabs.status": "状态",
"instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页", "instanceShell.rightPanel.tabs.ariaLabel": "右侧面板标签页",
"instanceShell.rightPanel.sections.sessionChanges": "会话更改", "instanceShell.rightPanel.sections.sessionChanges": "会话更改",
"instanceShell.rightPanel.sections.sessionChanges.tooltip": "当前会话中修改的文件。显示每个文件的添加和删除。",
"instanceShell.rightPanel.sections.plan": "计划", "instanceShell.rightPanel.sections.plan": "计划",
"instanceShell.rightPanel.sections.plan.tooltip": "代理的路线图。跟踪任务、子任务及其完成状态。",
"instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell", "instanceShell.rightPanel.sections.backgroundProcesses": "后台 Shell",
"instanceShell.rightPanel.sections.backgroundProcesses.tooltip": "代理启动的后台进程。您可以监控其输出、停止或终止它们。",
"instanceShell.rightPanel.sections.mcp": "MCP 服务器", "instanceShell.rightPanel.sections.mcp": "MCP 服务器",
"instanceShell.rightPanel.sections.mcp.tooltip": "模型上下文协议服务器,使用外部工具和服务扩展代理能力。",
"instanceShell.rightPanel.sections.lsp": "LSP 服务器", "instanceShell.rightPanel.sections.lsp": "LSP 服务器",
"instanceShell.rightPanel.sections.lsp.tooltip": "语言服务器协议服务器,提供代码智能、诊断和语言特定的功能。",
"instanceShell.rightPanel.sections.plugins": "插件", "instanceShell.rightPanel.sections.plugins": "插件",
"instanceShell.rightPanel.sections.plugins.tooltip": "自定义 UI 和服务器行为的插件,添加超出 MCP 和 LSP 的功能。",
"instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。", "instanceShell.sessionChanges.noSessionSelected": "选择会话以查看更改。",
"instanceShell.sessionChanges.loading": "正在获取会话更改...", "instanceShell.sessionChanges.loading": "正在获取会话更改...",

View File

@@ -15,4 +15,13 @@ export const logMessages = {
"infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。", "infoView.logs.paused.description": "启用流式输出以查看 OpenCode 服务器活动。",
"infoView.logs.empty.waiting": "正在等待服务器输出...", "infoView.logs.empty.waiting": "正在等待服务器输出...",
"infoView.logs.scrollToBottom": "滚动到底部", "infoView.logs.scrollToBottom": "滚动到底部",
"infoView.dispose.actions.dispose": "释放实例",
"infoView.dispose.actions.disposing": "正在释放...",
"infoView.dispose.confirm.title": "要释放实例吗?",
"infoView.dispose.confirm.message": "这将清除此目录的项目缓存状态,并重新加载实例。",
"infoView.dispose.confirm.confirmLabel": "释放",
"infoView.dispose.confirm.cancelLabel": "取消",
"infoView.dispose.toast.success": "实例已释放。正在重新加载...",
"infoView.dispose.toast.error": "释放实例失败。",
} as const } as const

View File

@@ -41,7 +41,7 @@ export const messagingMessages = {
"messageBlock.tool.goToSession.label": "前往会话", "messageBlock.tool.goToSession.label": "前往会话",
"messageBlock.tool.goToSession.title": "前往会话", "messageBlock.tool.goToSession.title": "前往会话",
"messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用", "messageBlock.tool.goToSession.unavailableTitle": "会话尚不可用",
"messageBlock.tool.deletePart.label": "删除", "messageBlock.tool.deletePart.label": "删除部分",
"messageBlock.tool.deletePart.deleting": "正在删除...", "messageBlock.tool.deletePart.deleting": "正在删除...",
"messageBlock.tool.deletePart.title": "删除此工具输出", "messageBlock.tool.deletePart.title": "删除此工具输出",
"messageBlock.tool.deletePart.failed.title": "删除失败", "messageBlock.tool.deletePart.failed.title": "删除失败",
@@ -71,17 +71,31 @@ export const messagingMessages = {
"messageItem.speaker.you": "你", "messageItem.speaker.you": "你",
"messageItem.speaker.assistant": "助手", "messageItem.speaker.assistant": "助手",
"messageItem.actions.revert": "回退", "messageItem.actions.revert": "回退",
"messageItem.actions.revertTitle": "回退到这条消息", "messageItem.actions.revertTitle": "撤销到此处的更改(会删除消息",
"messageItem.actions.fork": "分叉", "messageItem.actions.fork": "分叉",
"messageItem.actions.forkTitle": "从这条消息分叉", "messageItem.actions.forkTitle": "从这条消息分叉",
"messageItem.actions.copy": "复制", "messageItem.actions.copy": "复制",
"messageItem.actions.copyTitle": "复制消息", "messageItem.actions.copyTitle": "复制消息",
"messageItem.actions.copied": "已复制!", "messageItem.actions.copied": "已复制!",
"messageItem.actions.deleteMessage": "删除消息(不会撤销更改)",
"messageItem.actions.deleteMessagesUpTo": "删除到此处的消息(不会撤销更改)",
"messageItem.actions.deletingMessage": "正在删除...",
"messageItem.actions.deleteMessageFailedTitle": "删除失败",
"messageItem.actions.deleteMessageFailedMessage": "无法删除消息",
"messageItem.selection.checkboxAriaLabel": "选择要删除的消息",
"messageSection.bulkDelete.toolbarAriaLabel": "已选择的消息({count}",
"messageSection.bulkDelete.deleteSelectedTitle": "删除已选择的消息",
"messageSection.bulkDelete.selectAllTitle": "全选消息",
"messageSection.bulkDelete.cancelTitle": "取消选择",
"messageSection.bulkDelete.failedTitle": "删除失败",
"messageSection.bulkDelete.failedMessage": "无法删除已选择的消息",
"messageItem.status.queued": "排队中", "messageItem.status.queued": "排队中",
"messageItem.status.generating": "正在生成...", "messageItem.status.generating": "正在生成...",
"messageItem.status.sending": "正在发送...", "messageItem.status.sending": "正在发送...",
"messageItem.status.failedToSend": "消息发送失败", "messageItem.status.failedToSend": "消息发送失败",
"messagePart.actions.delete": "删除", "messagePart.actions.delete": "删除部分",
"messagePart.actions.deleting": "正在删除...", "messagePart.actions.deleting": "正在删除...",
"messagePart.actions.deleteTitle": "删除此项", "messagePart.actions.deleteTitle": "删除此项",
"messagePart.actions.deleteFailedTitle": "删除失败", "messagePart.actions.deleteFailedTitle": "删除失败",

View File

@@ -67,6 +67,8 @@ export const sessionMessages = {
"sessionView.alerts.abortFailed.title": "停止失败", "sessionView.alerts.abortFailed.title": "停止失败",
"sessionView.alerts.revertFailed.message": "回退到消息失败", "sessionView.alerts.revertFailed.message": "回退到消息失败",
"sessionView.alerts.revertFailed.title": "回退失败", "sessionView.alerts.revertFailed.title": "回退失败",
"sessionView.alerts.deleteUpToFailed.message": "无法删除消息",
"sessionView.alerts.deleteUpToFailed.title": "删除失败",
"sessionView.alerts.forkFailed.message": "分叉会话失败", "sessionView.alerts.forkFailed.message": "分叉会话失败",
"sessionView.alerts.forkFailed.title": "分叉失败", "sessionView.alerts.forkFailed.title": "分叉失败",
"sessionView.attachments.expandPastedTextAriaLabel": "展开粘贴的文本", "sessionView.attachments.expandPastedTextAriaLabel": "展开粘贴的文本",

View File

@@ -5,6 +5,14 @@ export const toolCallMessages = {
"toolCall.header.copyTitle": "Copy tool call title", "toolCall.header.copyTitle": "Copy tool call title",
"toolCall.header.copyAriaLabel": "Copy tool call title", "toolCall.header.copyAriaLabel": "Copy tool call title",
"toolCall.header.showInputTitle": "Show Tool Arguments",
"toolCall.header.showInputAriaLabel": "Show Tool Arguments",
"toolCall.header.hideInputTitle": "Hide Tool Arguments",
"toolCall.header.hideInputAriaLabel": "Hide Tool Arguments",
"toolCall.io.input": "Tool Input",
"toolCall.io.output": "Tool Output",
"toolCall.diff.label": "Diff", "toolCall.diff.label": "Diff",
"toolCall.diff.label.withPath": "Diff · {path}", "toolCall.diff.label.withPath": "Diff · {path}",
"toolCall.diff.viewMode.ariaLabel": "Diff 视图模式", "toolCall.diff.viewMode.ariaLabel": "Diff 视图模式",

View File

@@ -0,0 +1,29 @@
export function formatLaunchErrorMessage(error: unknown, fallbackMessage: string): string {
if (!error) {
return fallbackMessage
}
const raw = typeof error === "string" ? error : error instanceof Error ? error.message : String(error)
try {
const parsed = JSON.parse(raw) as unknown
if (parsed && typeof parsed === "object" && "error" in parsed && typeof (parsed as any).error === "string") {
return (parsed as any).error
}
} catch {
// ignore JSON parse errors
}
return raw
}
export function isMissingBinaryMessage(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes("opencode binary not found") ||
normalized.includes("binary not found") ||
normalized.includes("no such file or directory") ||
normalized.includes("binary is not executable") ||
normalized.includes("enoent")
)
}

Some files were not shown because too many files have changed in this diff Show More