Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline 8abfd71684 refactor(apply_patch): avoid redundant file reads during update verification
Make patch derivation async and allow callers to provide existing file content so apply_patch update checks reuse already-loaded text instead of reading the same file twice.
2026-02-17 19:51:23 -06:00
289 changed files with 4311 additions and 9876 deletions
-15
View File
@@ -1,15 +0,0 @@
adamdotdevin
Brendonovich
fwang
Hona
iamdavidhill
jayair
jlongster
kitlangton
kommander
MrMushrooooom
nexxeln
R44VC0RP
rekram1-node
RhysSullivan
thdxr
+5 -6
View File
@@ -3,13 +3,12 @@ description: "Setup Bun with caching and install dependencies"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Cache Bun dependencies - name: Mount Bun Cache
uses: actions/cache@v4 if: ${{ runner.os == 'Linux' }}
uses: useblacksmith/stickydisk@v1
with: with:
path: ~/.bun/install/cache key: ${{ github.repository }}-bun-cache-${{ runner.os }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} path: ~/.bun
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2
+1 -23
View File
@@ -1,29 +1,7 @@
### Issue for this PR
Closes #
### Type of change
- [ ] Bug fix
- [ ] New feature
- [ ] Refactor / code improvement
- [ ] Documentation
### What does this PR do? ### What does this PR do?
Please provide a description of the issue, the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR.
**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
### How did you verify your code works? ### How did you verify your code works?
### Screenshots / recordings
_If this is a UI change, please include a screenshot or recording._
### Checklist
- [ ] I have tested my changes locally
- [ ] I have not included unrelated changes in this PR
_If you do not follow this template your PR will be automatically rejected._
+2 -62
View File
@@ -2,11 +2,10 @@ name: duplicate-issues
on: on:
issues: issues:
types: [opened, edited] types: [opened]
jobs: jobs:
check-duplicates: check-duplicates:
if: github.event.action == 'opened'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: read contents: read
@@ -35,7 +34,7 @@ jobs:
"webfetch": "deny" "webfetch": "deny"
} }
run: | run: |
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:
Issue number: ${{ github.event.issue.number }} Issue number: ${{ github.event.issue.number }}
@@ -116,62 +115,3 @@ jobs:
If you believe this was flagged incorrectly, please let a maintainer know. If you believe this was flagged incorrectly, please let a maintainer know.
Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." Remember: post at most ONE comment combining all findings. If everything is fine, post nothing."
recheck-compliance:
if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance')
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Recheck compliance
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
},
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
Re-check whether the issue now follows our contributing guidelines and issue templates.
This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content
2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]:
3. Question - requires the Question field with real content
Additionally check:
- No AI-generated walls of text (long, AI-generated descriptions are not acceptable)
- The issue has real content, not just template placeholder text left unchanged
- Bug reports should include some context about how to reproduce
- Feature requests should explain the problem or need
- We want to push for having the user provide system description & information
Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content.
If the issue is NOW compliant:
1. Remove the needs:compliance label: gh issue edit ${{ github.event.issue.number }} --remove-label needs:compliance
2. Find and delete the previous compliance comment (the one containing <!-- issue-compliance -->) using: gh api repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments --jq '.[] | select(.body | contains(\"<!-- issue-compliance -->\")) | .id' then delete it with: gh api -X DELETE repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments/{id}
3. Post a short comment thanking them for updating the issue.
If the issue is STILL not compliant:
Post a comment explaining what still needs to be fixed. Keep the needs:compliance label."
@@ -0,0 +1,46 @@
name: nix-desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
- ".github/workflows/nix-desktop.yml"
workflow_dispatch:
jobs:
nix-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15-intel
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
-95
View File
@@ -1,95 +0,0 @@
name: nix-eval
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
nix-eval:
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Evaluate flake outputs (all systems)
run: |
set -euo pipefail
nix --version
echo "=== Flake metadata ==="
nix flake metadata
echo ""
echo "=== Flake structure ==="
nix flake show --all-systems
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
PACKAGES="opencode"
# TODO: move 'desktop' to PACKAGES when #11755 is fixed
OPTIONAL_PACKAGES="desktop"
echo ""
echo "=== Evaluating packages for all systems ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for packages.$system.$pkg"
echo "$output"
exit 1
fi
done
done
echo ""
echo "=== Evaluating optional packages ==="
for system in $SYSTEMS; do
echo ""
echo "--- $system ---"
for pkg in $OPTIONAL_PACKAGES; do
printf " %s: " "$pkg"
if output=$(nix eval ".#packages.$system.$pkg.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::warning::Evaluation failed for packages.$system.$pkg"
echo "$output"
fi
done
done
echo ""
echo "=== Evaluating devShells for all systems ==="
for system in $SYSTEMS; do
printf "%s: " "$system"
if output=$(nix eval ".#devShells.$system.default.drvPath" --raw 2>&1); then
echo "✓"
else
echo "✗"
echo "::error::Evaluation failed for devShells.$system.default"
echo "$output"
exit 1
fi
done
echo ""
echo "=== All evaluations passed ==="
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev, beta] branches: [dev]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"
+11 -16
View File
@@ -6,6 +6,17 @@ on:
jobs: jobs:
check-duplicates: check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: read contents: read
@@ -16,31 +27,16 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Check team membership
id: team-check
run: |
LOGIN="${{ github.event.pull_request.user.login }}"
if [ "$LOGIN" = "opencode-agent[bot]" ] || grep -qxF "$LOGIN" .github/TEAM_MEMBERS; then
echo "is_team=true" >> "$GITHUB_OUTPUT"
echo "Skipping: $LOGIN is a team member or bot"
else
echo "is_team=false" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun - name: Setup Bun
if: steps.team-check.outputs.is_team != 'true'
uses: ./.github/actions/setup-bun uses: ./.github/actions/setup-bun
- name: Install dependencies - name: Install dependencies
if: steps.team-check.outputs.is_team != 'true'
run: bun install run: bun install
- name: Install opencode - name: Install opencode
if: steps.team-check.outputs.is_team != 'true'
run: curl -fsSL https://opencode.ai/install | bash run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt - name: Build prompt
if: steps.team-check.outputs.is_team != 'true'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
@@ -57,7 +53,6 @@ jobs:
} > pr_info.txt } > pr_info.txt
- name: Check for duplicate PRs - name: Check for duplicate PRs
if: steps.team-check.outputs.is_team != 'true'
env: env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+11 -223
View File
@@ -6,9 +6,19 @@ on:
jobs: jobs:
check-standards: check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
pull-requests: write pull-requests: write
steps: steps:
- name: Check PR standards - name: Check PR standards
@@ -16,30 +26,6 @@ jobs:
with: with:
script: | script: |
const pr = context.payload.pull_request; const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const title = pr.title; const title = pr.title;
async function addLabel(label) { async function addLabel(label) {
@@ -151,201 +137,3 @@ jobs:
await removeLabel('needs:issue'); await removeLabel('needs:issue');
console.log('PR meets all standards'); console.log('PR meets all standards');
check-compliance:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Check PR template compliance
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const login = pr.user.login;
// Skip PRs older than Feb 18, 2026 at 6PM EST (Feb 19, 2026 00:00 UTC)
const cutoff = new Date('2026-02-19T00:00:00Z');
const prCreated = new Date(pr.created_at);
if (prCreated < cutoff) {
console.log(`Skipping: PR #${pr.number} was created before cutoff (${prCreated.toISOString()})`);
return;
}
// Check if author is a team member or bot
if (login === 'opencode-agent[bot]') return;
const { data: file } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path: '.github/TEAM_MEMBERS',
ref: 'dev'
});
const members = Buffer.from(file.content, 'base64').toString().split('\n').map(l => l.trim()).filter(Boolean);
if (members.includes(login)) {
console.log(`Skipping: ${login} is a team member`);
return;
}
const body = pr.body || '';
const title = pr.title;
const isDocsOrRefactor = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
const issues = [];
// Check: template sections exist
const hasWhatSection = /### What does this PR do\?/.test(body);
const hasTypeSection = /### Type of change/.test(body);
const hasVerifySection = /### How did you verify your code works\?/.test(body);
const hasChecklistSection = /### Checklist/.test(body);
const hasIssueSection = /### Issue for this PR/.test(body);
if (!hasWhatSection || !hasTypeSection || !hasVerifySection || !hasChecklistSection || !hasIssueSection) {
issues.push('PR description is missing required template sections. Please use the [PR template](../blob/dev/.github/pull_request_template.md).');
}
// Check: "What does this PR do?" has real content (not just placeholder text)
if (hasWhatSection) {
const whatMatch = body.match(/### What does this PR do\?\s*\n([\s\S]*?)(?=###|$)/);
const whatContent = whatMatch ? whatMatch[1].trim() : '';
const placeholder = 'Please provide a description of the issue';
const onlyPlaceholder = whatContent.includes(placeholder) && whatContent.replace(placeholder, '').replace(/[*\s]/g, '').length < 20;
if (!whatContent || onlyPlaceholder) {
issues.push('"What does this PR do?" section is empty or only contains placeholder text. Please describe your changes.');
}
}
// Check: at least one "Type of change" checkbox is checked
if (hasTypeSection) {
const typeMatch = body.match(/### Type of change\s*\n([\s\S]*?)(?=###|$)/);
const typeContent = typeMatch ? typeMatch[1] : '';
const hasCheckedBox = /- \[x\]/i.test(typeContent);
if (!hasCheckedBox) {
issues.push('No "Type of change" checkbox is checked. Please select at least one.');
}
}
// Check: issue reference (skip for docs/refactor)
if (!isDocsOrRefactor && hasIssueSection) {
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : '';
const hasIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
if (!hasIssueRef) {
issues.push('No issue referenced. Please add `Closes #<number>` linking to the relevant issue.');
}
}
// Check: "How did you verify" has content
if (hasVerifySection) {
const verifyMatch = body.match(/### How did you verify your code works\?\s*\n([\s\S]*?)(?=###|$)/);
const verifyContent = verifyMatch ? verifyMatch[1].trim() : '';
if (!verifyContent) {
issues.push('"How did you verify your code works?" section is empty. Please explain how you tested.');
}
}
// Check: checklist boxes are checked
if (hasChecklistSection) {
const checklistMatch = body.match(/### Checklist\s*\n([\s\S]*?)(?=###|$)/);
const checklistContent = checklistMatch ? checklistMatch[1] : '';
const unchecked = (checklistContent.match(/- \[ \]/g) || []).length;
const checked = (checklistContent.match(/- \[x\]/gi) || []).length;
if (checked < 2) {
issues.push('Not all checklist items are checked. Please confirm you have tested locally and have not included unrelated changes.');
}
}
// Helper functions
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {}
}
const hasComplianceLabel = pr.labels.some(l => l.name === 'needs:compliance');
if (issues.length > 0) {
// Non-compliant
if (!hasComplianceLabel) {
await addLabel('needs:compliance');
}
const marker = '<!-- issue-compliance -->';
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(marker));
const body_text = `${marker}
This PR doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) and [PR template](../blob/dev/.github/pull_request_template.md).
**What needs to be fixed:**
${issues.map(i => `- ${i}`).join('\n')}
Please edit this PR description to address the above within **2 hours**, or it will be automatically closed.
If you believe this was flagged incorrectly, please let a maintainer know.`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body_text
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: body_text
});
}
console.log(`PR #${pr.number} is non-compliant: ${issues.join(', ')}`);
} else if (hasComplianceLabel) {
// Was non-compliant, now fixed
await removeLabel('needs:compliance');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const marker = '<!-- issue-compliance -->';
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id
});
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: 'Thanks for updating your PR! It now meets our contributing guidelines. :+1:'
});
console.log(`PR #${pr.number} is now compliant, label removed`);
} else {
console.log(`PR #${pr.number} is compliant`);
}
+42
View File
@@ -0,0 +1,42 @@
---
name: bun-file-io
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
---
## Use this when
- Editing file I/O or scans in `packages/opencode`
- Handling directory operations or external tools
## Bun file APIs (from Bun docs)
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
- Metadata: `file.size`, `file.type`, `file.name`.
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
- `Bun.file(...).delete()` deletes a file.
- `file.writer()` returns a FileSink for incremental writes.
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
## When to use node:fs
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
## Repo patterns
- Prefer Bun APIs over Node `fs` for file access.
- Check `Bun.file(...).exists()` before reading.
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
- Use `Bun.Glob` + `Array.fromAsync` for scans.
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
Use Filesystem.exists(...) instead if path can be file or directory
## Quick checklist
- Use Bun APIs first.
- Use `path.join`/`path.resolve` for paths.
- Prefer promise `.catch(...)` over `try/catch` when possible.
-9
View File
@@ -1,9 +0,0 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}
-5
View File
@@ -24,11 +24,6 @@ If you are unsure if a PR would be accepted, feel free to ask a maintainer or lo
Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on. Want to take on an issue? Leave a comment and a maintainer may assign it to you unless it is something we are already working on.
## Adding New Providers
New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
## Developing OpenCode ## Developing OpenCode
- Requirements: Bun 1.3+ - Requirements: Bun 1.3+
-6
View File
@@ -1,11 +1,5 @@
# Security # Security
## IMPORTANT
We do not accept AI generated security reports. We receive a large number of
these and we absolutely do not have the resources to review them all. If you
submit one that will be an automatic ban from the project.
## Threat Model ## Threat Model
### Overview ### Overview
+101 -368
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2,7 +2,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* deno-fmt-ignore-file */ /* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../sst-env.d.ts" /> /// <reference path="../sst-env.d.ts" />
+1 -1
View File
@@ -214,9 +214,9 @@ new sst.cloudflare.x.SolidStart("Console", {
}, },
transform: { transform: {
server: { server: {
placement: { region: "aws:us-east-1" },
transform: { transform: {
worker: { worker: {
placement: { mode: "smart" },
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
}, },
}, },
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-fjrvCgQ2PHYxzw8NsiEHOcor46qN95/cfilFHFqCp/k=", "x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
"aarch64-linux": "sha256-xWp4LLJrbrCPFL1F6SSbProq/t/az4CqhTcymPvjOBQ=", "aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
"aarch64-darwin": "sha256-Wbfyy/bruFHKUWsyJ2aiPXAzLkk5MNBfN6QdGPQwZS0=", "aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
"x86_64-darwin": "sha256-wDnMbiaBCRj5STkaLoVCZTdXVde+/YKfwWzwJZ1AJXQ=" "x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
} }
} }
+1 -3
View File
@@ -69,12 +69,10 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7", "husky": "9.1.7",
"prettier": "3.6.2", "prettier": "3.6.2",
"semver": "^7.6.0", "semver": "^7.6.0",
"sst": "3.18.10", "sst": "3.17.23",
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
-157
View File
@@ -332,163 +332,6 @@ export async function withSession<T>(
} }
} }
const seedSystem = [
"You are seeding deterministic e2e UI state.",
"Follow the user's instruction exactly.",
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
"Do not call any extra tools.",
].join(" ")
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
const timeout = input.timeout ?? 30_000
const end = Date.now() + timeout
while (Date.now() < end) {
const value = await input.probe()
if (value !== undefined) return value
await new Promise((resolve) => setTimeout(resolve, 250))
}
}
const seed = async <T>(input: {
sessionID: string
prompt: string
sdk: ReturnType<typeof createSdk>
probe: () => Promise<T | undefined>
timeout?: number
attempts?: number
}) => {
for (let i = 0; i < (input.attempts ?? 2); i++) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
agent: "build",
system: seedSystem,
parts: [{ type: "text", text: input.prompt }],
})
const value = await wait({ probe: input.probe, timeout: input.timeout })
if (value !== undefined) return value
}
}
export async function seedSessionQuestion(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
questions: Array<{
header: string
question: string
options: Array<{ label: string; description: string }>
multiple?: boolean
custom?: boolean
}>
},
) {
const first = input.questions[0]
if (!first) throw new Error("Question seed requires at least one question")
const text = [
"Your only valid response is one question tool call.",
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
"Do not output plain text.",
"After calling the tool, wait for the user response.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.question.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
},
})
if (!result) throw new Error("Timed out seeding question request")
return { id: result.id }
}
export async function seedSessionPermission(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
permission: string
patterns: string[]
description?: string
},
) {
const text = [
"Your only valid response is one bash tool call.",
`Use this JSON input: ${JSON.stringify({
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
workdir: "/",
description: input.description ?? `seed ${input.permission} permission request`,
})}`,
"Do not output plain text.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const list = await sdk.permission.list().then((x) => x.data ?? [])
return list.find((item) => item.sessionID === input.sessionID)
},
})
if (!result) throw new Error("Timed out seeding permission request")
return { id: result.id }
}
export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
todos: Array<{ content: string; status: string; priority: string }>
},
) {
const text = [
"Your only valid response is one todowrite tool call.",
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
"Do not output plain text.",
].join("\n")
const target = JSON.stringify(input.todos)
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 30_000,
probe: async () => {
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
if (JSON.stringify(todos) !== target) return
return true
},
})
if (!result) throw new Error("Timed out seeding todos")
return true
}
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const [questions, permissions] = await Promise.all([
sdk.question.list().then((x) => x.data ?? []),
sdk.permission.list().then((x) => x.data ?? []),
])
await Promise.all([
...questions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
...permissions
.filter((item) => item.sessionID === sessionID)
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
])
return true
}
export async function openStatusPopover(page: Page) { export async function openStatusPopover(page: Page) {
await defocus(page) await defocus(page)
@@ -1,19 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { import { defocus, createTestProject, cleanupTestProject } from "../actions"
defocus, import { projectSwitchSelector } from "../selectors"
createTestProject, import { dirSlug } from "../utils"
cleanupTestProject,
openSidebar,
setWorkspacesEnabled,
sessionIDFromUrl,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
test("can switch between projects from sidebar", async ({ page, withProject }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
@@ -45,94 +33,3 @@ test("can switch between projects from sidebar", async ({ page, withProject }) =
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
const stamp = Date.now()
let rootDir: string | undefined
let workspaceDir: string | undefined
let sessionID: string | undefined
try {
await withProject(
async ({ directory, slug }) => {
rootDir = directory
await defocus(page)
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const next = slugFromUrl(page.url())
if (!next) return ""
if (next === slug) return ""
return next
},
{ timeout: 45_000 },
)
.not.toBe("")
const workspaceSlug = slugFromUrl(page.url())
workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(workspace).toBeVisible()
await workspace.hover()
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
await expect(newSession).toBeVisible()
await newSession.click({ force: true })
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(`project switch remembers workspace ${stamp}`)
await prompt.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
sessionID = created
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },
)
} finally {
if (sessionID) {
const id = sessionID
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
await Promise.all(
dirs.map((directory) =>
createSdk(directory)
.session.delete({ sessionID: id })
.catch(() => undefined),
),
)
}
if (workspaceDir) {
await cleanupTestProject(workspaceDir)
}
await cleanupTestProject(other)
}
})
-10
View File
@@ -1,15 +1,5 @@
export const promptSelector = '[data-component="prompt-input"]' export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]' export const terminalSelector = '[data-component="terminal"]'
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
@@ -1,207 +0,0 @@
import { test, expect } from "../fixtures"
import { clearSessionDockSeed, seedSessionPermission, seedSessionQuestion, seedSessionTodos } from "../actions"
import {
permissionDockSelector,
promptSelector,
questionDockSelector,
sessionComposerDockSelector,
sessionTodoDockSelector,
sessionTodoListSelector,
sessionTodoToggleButtonSelector,
} from "../selectors"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
async function withDockSession<T>(sdk: Sdk, title: string, fn: (session: { id: string; title: string }) => Promise<T>) {
const session = await sdk.session.create({ title }).then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
return fn(session)
}
test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
try {
return await fn()
} finally {
await clearSessionDockSeed(sdk, sessionID).catch(() => undefined)
}
}
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => {
await gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
})
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow once/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["REJECT.md"],
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator(permissionDockSelector).getByRole("button", { name: /deny/i }).click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionPermission(sdk, {
sessionID: session.id,
permission: "bash",
patterns: ["README.md"],
description: "Need permission for command",
})
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page
.locator(permissionDockSelector)
.getByRole("button", { name: /allow always/i })
.click()
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
await page.locator(sessionTodoToggleButtonSelector).click()
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
await seedSessionTodos(sdk, {
sessionID: session.id,
todos: [
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
],
})
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(sdk, session.id, async () => {
await gotoSession(session.id)
await seedSessionQuestion(sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
await expect(page.locator(promptSelector)).toHaveCount(0)
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.8", "version": "1.2.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+64 -37
View File
@@ -1,36 +1,35 @@
import "@/index.css" import "@/index.css"
import { Code } from "@opencode-ai/ui/code" import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { I18nProvider } from "@opencode-ai/ui/context" import { Router, Route, Navigate } from "@solidjs/router"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { Diff } from "@opencode-ai/ui/diff"
import { Font } from "@opencode-ai/ui/font"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Navigate, Route, Router } from "@solidjs/router" import { Font } from "@opencode-ai/ui/font"
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { CommandProvider } from "@/context/command" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CommentsProvider } from "@/context/comments" import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { FileProvider } from "@/context/file" import { I18nProvider } from "@opencode-ai/ui/context"
import { GlobalSDKProvider } from "@/context/global-sdk" import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission" import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform" import { LayoutProvider } from "@/context/layout"
import { PromptProvider } from "@/context/prompt" import { GlobalSDKProvider } from "@/context/global-sdk"
import { type ServerConnection, ServerProvider, useServer } from "@/context/server" import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings" import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout" import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
import { CommentsProvider } from "@/context/comments"
import { NotificationProvider } from "@/context/notification"
import { ModelsProvider } from "@/context/models"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { LanguageProvider, useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
@@ -58,11 +57,7 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
} }
} }
@@ -112,6 +107,30 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -138,19 +157,27 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
<Show when={server.key} keyed> <Show when={server.url} keyed>
{props.children} {props.children}
</Show> </Show>
) )
} }
export function AppInterface(props: { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
children?: JSX.Element const platform = usePlatform()
defaultServer: ServerConnection.Key const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
servers?: Array<ServerConnection.Any> const defaultServerUrl = resolveDefaultServerUrl({
}) { defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return ( return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}> <ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
@@ -1,18 +1,19 @@
import { Button } from "@opencode-ai/ui/button" import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { normalizeServerUrl, useServer } from "@/context/server"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
@@ -88,7 +89,7 @@ function useServerPreview(fetcher: typeof fetch) {
if (!looksComplete(value)) return if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) return if (!normalized) return
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@@ -170,13 +171,14 @@ export function DialogSelectServer() {
const dialog = useDialog() const dialog = useDialog()
const server = useServer() const server = useServer()
const platform = usePlatform() const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher) const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, status: {} as Record<string, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@@ -212,25 +214,24 @@ export function DialogSelectServer() {
}) })
} }
const replaceServer = (original: ServerConnection.Http, next: string) => { const replaceServer = (original: string, next: string) => {
const active = server.key const active = server.url
const newConn = server.add(next) const nextActive = active === original ? next : active
if (!newConn) return
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active server.add(next)
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(ServerConnection.key(original)) server.remove(original)
} }
const items = createMemo(() => { const items = createMemo(() => {
const current = server.current const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@@ -245,17 +246,17 @@ export function DialogSelectServer() {
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (a === active) return -1 if (a === active) return -1
if (b === active) return 1 if (b === active) return 1
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
}) })
async function refreshHealth() { async function refreshHealth() {
const results: Record<ServerConnection.Key, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (conn) => { items().map(async (url) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@@ -268,15 +269,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
async function select(conn: ServerConnection.Any, persist?: boolean) { async function select(value: string, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return if (!persist && store.status[value]?.healthy === false) return
dialog.close() dialog.close()
if (persist) { if (persist) {
server.add(conn.http.url) server.add(value)
navigate("/") navigate("/")
return return
} }
server.setActive(ServerConnection.key(conn)) server.setActive(value)
navigate("/") navigate("/")
} }
@@ -310,7 +311,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@@ -319,25 +320,25 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
await select({ type: "http", http: { url: normalized } }, true) await select(normalized, true)
} }
async function handleEdit(original: ServerConnection.Any, value: string) { async function handleEdit(original: string, value: string) {
if (store.editServer.busy || original.type !== "http") return if (store.editServer.busy) return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) { if (!normalized) {
resetEdit() resetEdit()
return return
} }
if (normalized === original.http.url) { if (normalized === original) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth({ url: normalized }, fetcher) const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@@ -365,7 +366,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url) handleAdd(store.addServer.url)
} }
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => { const handleEditKey = (event: KeyboardEvent, original: string) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
@@ -377,7 +378,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value) handleEdit(original, store.editServer.value)
} }
async function handleRemove(url: ServerConnection.Key) { async function handleRemove(url: string) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null) platform.setDefaultServerUrl?.(null)
@@ -389,14 +390,11 @@ export function DialogSelectServer() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}> <div ref={(el) => (listRoot = el)}>
<List <List
search={{ search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection noInitialSelection
emptyMessage={language.t("dialog.server.empty")} emptyMessage={language.t("dialog.server.empty")}
items={sortedItems} items={sortedItems}
key={(x) => x.http.url} key={(x) => x}
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
@@ -427,11 +425,10 @@ export function DialogSelectServer() {
} }
> >
{(i) => { {(i) => {
const key = ServerConnection.key(i)
return ( return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show <Show
when={store.editServer.id !== i.http.url} when={store.editServer.id !== i}
fallback={ fallback={
<EditRow <EditRow
value={store.editServer.value} value={store.editServer.value}
@@ -446,12 +443,12 @@ export function DialogSelectServer() {
} }
> >
<ServerRow <ServerRow
conn={i} url={i}
status={store.status[key]} status={store.status[i]}
dimmed={store.status[key]?.healthy === false} dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1" class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={ badge={
<Show when={defaultUrl() === i.http.url}> <Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")} {language.t("dialog.server.status.default")}
</span> </span>
@@ -459,13 +456,12 @@ export function DialogSelectServer() {
} }
/> />
</Show> </Show>
<Show when={store.editServer.id !== i.http.url}> <Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4"> <div class="flex items-center justify-center gap-5 pl-4">
<Show when={ServerConnection.key(current()) === key}> <Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show> </Show>
<Show when={i.type === "http"}>
<DropdownMenu> <DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
@@ -480,23 +476,23 @@ export function DialogSelectServer() {
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setStore("editServer", { setStore("editServer", {
id: i.http.url, id: i,
value: i.http.url, value: i,
error: "", error: "",
status: store.status[ServerConnection.key(i)]?.healthy, status: store.status[i]?.healthy,
}) })
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i.http.url}> <Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}> <DropdownMenu.Item onSelect={() => setDefault(i)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </Show>
<Show when={canDefault() && defaultUrl() === i.http.url}> <Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}> <DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")} {language.t("dialog.server.menu.defaultRemove")}
@@ -505,17 +501,14 @@ export function DialogSelectServer() {
</Show> </Show>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))} onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak" class="text-text-on-critical-base hover:bg-surface-critical-weak"
> >
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>
</Show>
</div> </div>
</Show> </Show>
</div> </div>
+2 -9
View File
@@ -550,15 +550,8 @@ export default function FileTree(props: {
</Match> </Match>
<Match when={!node.ignored}> <Match when={!node.ignored}>
<span class="filetree-iconpair size-4"> <span class="filetree-iconpair size-4">
<FileIcon <FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
node={node} <FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
class="size-4 filetree-icon filetree-icon--color opacity-0 group-hover/filetree:opacity-100"
/>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono group-hover/filetree:opacity-0"
mono
/>
</span> </span>
</Match> </Match>
</Switch> </Switch>
+21 -16
View File
@@ -20,7 +20,6 @@ import { useParams } from "@solidjs/router"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider" import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -404,10 +403,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const [composing, setComposing] = createSignal(false) const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const handleBlur = () => { createEffect(() => {
closePopover() if (!isFocused()) closePopover()
setComposing(false) })
}
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
const agentList = createMemo(() => const agentList = createMemo(() =>
sync.data.agent sync.data.agent
@@ -1046,11 +1050,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind} commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<DockShellForm <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true, "group/prompt-input": true,
"focus-within:shadow-xs-border": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
"rounded-[12px] overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null, "border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,
}} }}
@@ -1113,7 +1118,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={() => setComposing(true)} onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)} onCompositionEnd={() => setComposing(false)}
onBlur={handleBlur}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true, "select-text": true,
@@ -1243,10 +1247,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div> </div>
</Show> </Show>
</div> </div>
</DockShellForm> </form>
<Show when={store.mode === "normal" || store.mode === "shell"}> <Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top"> <div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1"> <div class="flex items-center gap-1.5 min-w-0 flex-1">
<Show when={store.mode === "shell"}> <Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}> <div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
@@ -1254,6 +1258,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" /> <div class="size-4 shrink-0" />
</div> </div>
</Show> </Show>
<Show when={store.mode === "normal"}> <Show when={store.mode === "normal"}>
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
@@ -1353,7 +1358,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</TooltipKeybind> </TooltipKeybind>
</Show> </Show>
</div> </div>
<div class="shrink-0"> <div class="shrink-0" data-component="prompt-mode-toggle">
<RadioGroup <RadioGroup
options={["shell", "normal"] as const} options={["shell", "normal"] as const}
current={store.mode} current={store.mode}
@@ -1362,8 +1367,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<TooltipKeybind <TooltipKeybind
placement="top" placement="top"
gutter={4} gutter={4}
openDelay={2000} title={language.t(mode === "shell" ? "command.prompt.mode.shell" : "command.prompt.mode.normal")}
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")} keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
class="size-full flex items-center justify-center" class="size-full flex items-center justify-center"
> >
@@ -1371,7 +1375,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
name={mode === "shell" ? "console" : "prompt"} name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]" class="size-[18px]"
classList={{ classList={{
"text-icon-strong-base": store.mode === mode, "text-icon-strong-base": mode === "shell" && store.mode === "shell",
"text-icon-interactive-base": mode === "normal" && store.mode === "normal",
"text-icon-weak": store.mode !== mode, "text-icon-weak": store.mode !== mode,
}} }}
/> />
@@ -1384,7 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
/> />
</div> </div>
</div> </div>
</DockTray> </div>
</Show> </Show>
</div> </div>
) )
@@ -12,9 +12,7 @@ let selected = "/repo/worktree-a"
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const clientFor = (directory: string) => { const clientFor = (directory: string) => ({
createdClients.push(directory)
return {
session: { session: {
create: async () => { create: async () => {
createdSessions.push(directory) createdSessions.push(directory)
@@ -31,8 +29,7 @@ const clientFor = (directory: string) => {
worktree: { worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }), create: async () => ({ data: { directory: `${directory}/new` } }),
}, },
} })
}
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
@@ -91,17 +88,11 @@ beforeAll(async () => {
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => ({
const sdk = {
directory: "/repo/main", directory: "/repo/main",
client: rootClient, client: rootClient,
url: "http://localhost:4096", url: "http://localhost:4096",
createClient(opts: any) { }),
return clientFor(opts.directory)
},
}
return sdk
},
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
@@ -1,20 +1,21 @@
import type { Message } from "@opencode-ai/sdk/v2/client" import { Accessor } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useNavigate, useParams } from "@solidjs/router"
import type { Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { buildRequestParts } from "./build-request-parts" import type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@@ -55,6 +56,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK() const sdk = useSDK()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal() const local = useLocal()
const prompt = usePrompt() const prompt = usePrompt()
const layout = useLayout() const layout = useLayout()
@@ -73,16 +75,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => { const abort = async () => {
const sessionID = params.id const sessionID = params.id
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
const queued = pending.get(sessionID) const queued = pending.get(sessionID)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
pending.delete(sessionID) pending.delete(sessionID)
globalSync.todo.set(sessionID, undefined)
return Promise.resolve() return Promise.resolve()
} }
return sdk.client.session return sdk.client.session
@@ -90,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID, sessionID,
}) })
.catch(() => {}) .catch(() => {})
.finally(() => {
globalSync.todo.set(sessionID, undefined)
})
} }
const restoreCommentItems = (items: CommentItem[]) => { const restoreCommentItems = (items: CommentItem[]) => {
@@ -174,7 +175,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = sdk.createClient({ client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
@@ -369,10 +372,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const timer = { id: undefined as number | undefined } const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => { timer.id = window.setTimeout(() => {
resolve({ resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs) }, timeoutMs)
}) })
@@ -8,7 +8,7 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -115,7 +115,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const reply = async (answers: QuestionAnswer[]) => { const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return if (store.sending) return
props.onSubmit()
setStore("sending", true) setStore("sending", true)
try { try {
await sdk.client.question.reply({ requestID: props.request.id, answers }) await sdk.client.question.reply({ requestID: props.request.id, answers })
@@ -129,7 +128,6 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const reject = async () => { const reject = async () => {
if (store.sending) return if (store.sending) return
props.onSubmit()
setStore("sending", true) setStore("sending", true)
try { try {
await sdk.client.question.reject({ requestID: props.request.id }) await sdk.client.question.reject({ requestID: props.request.id })
@@ -1,19 +1,10 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
createEffect, import { serverDisplayName } from "@/context/server"
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
} from "solid-js"
import { type ServerConnection, serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps { interface ServerRowProps extends ParentProps {
conn: ServerConnection.Any url: string
status?: ServerHealth status?: ServerHealth
class?: string class?: string
nameClass?: string nameClass?: string
@@ -26,7 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.conn)) const name = createMemo(() => serverDisplayName(props.url))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -36,7 +27,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => { createEffect(() => {
name() name()
props.conn.http.url props.url
props.status?.version props.status?.version
queueMicrotask(check) queueMicrotask(check)
}) })
@@ -1,6 +1,5 @@
import type { Todo } from "@opencode-ai/sdk/v2" import type { Todo } from "@opencode-ai/sdk/v2"
import { Checkbox } from "@opencode-ai/ui/checkbox" import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
@@ -55,14 +54,13 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
const preview = createMemo(() => active()?.content ?? "") const preview = createMemo(() => active()?.content ?? "")
return ( return (
<DockTray <div
data-component="session-todo-dock"
classList={{ classList={{
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
"h-[78px]": store.collapsed, "h-[78px]": store.collapsed,
}} }}
> >
<div <div
data-action="session-todo-toggle"
class="pl-3 pr-2 py-2 flex items-center gap-2" class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button" role="button"
tabIndex={0} tabIndex={0}
@@ -83,7 +81,6 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
</Show> </Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}> <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton <IconButton
data-action="session-todo-toggle-button"
icon="chevron-down" icon="chevron-down"
size="normal" size="normal"
variant="ghost" variant="ghost"
@@ -101,10 +98,10 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
</div> </div>
</div> </div>
<div data-slot="session-todo-list" hidden={store.collapsed}> <div hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} /> <TodoList todos={props.todos} open={!store.collapsed} />
</div> </div>
</DockTray> </div>
) )
} }
@@ -5,7 +5,6 @@ import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array" import { findLast } from "@opencode-ai/util/array"
import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -17,6 +16,13 @@ import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = { const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)", system: "var(--syntax-info)",
user: "var(--syntax-success)", user: "var(--syntax-success)",
@@ -85,45 +91,11 @@ function RawMessage(props: {
) )
} }
const emptyMessages: Message[] = [] export function SessionContextTab(props: SessionContextTabProps) {
const emptyUserMessages: UserMessage[] = []
export function SessionContextTab() {
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
const layout = useLayout()
const language = useLanguage() const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(
() => {
const id = params.id
if (!id) return emptyMessages
return (sync.data.message[id] ?? []) as Message[]
},
emptyMessages,
{ equals: same },
)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const usd = createMemo( const usd = createMemo(
() => () =>
new Intl.NumberFormat(language.locale(), { new Intl.NumberFormat(language.locale(), {
@@ -132,7 +104,7 @@ export function SessionContextTab() {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale())) const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
@@ -141,7 +113,7 @@ export function SessionContextTab() {
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
const all = messages() const all = props.messages()
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0) const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0) const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
return { return {
@@ -152,7 +124,7 @@ export function SessionContextTab() {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const msg = findLast(visibleUserMessages(), (m) => !!m.system) const msg = findLast(props.visibleUserMessages(), (m) => !!m.system)
const system = msg?.system const system = msg?.system
if (!system) return if (!system) return
const trimmed = system.trim() const trimmed = system.trim()
@@ -174,12 +146,12 @@ export function SessionContextTab() {
const breakdown = createMemo( const breakdown = createMemo(
on( on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c?.input) return [] if (!c?.input) return []
return estimateSessionContextBreakdown({ return estimateSessionContextBreakdown({
messages: messages(), messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>, parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input, input: c.input,
systemPrompt: systemPrompt(), systemPrompt: systemPrompt(),
@@ -197,7 +169,7 @@ export function SessionContextTab() {
} }
const stats = [ const stats = [
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
@@ -214,7 +186,7 @@ export function SessionContextTab() {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost }, { label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) }, { label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
@@ -227,7 +199,7 @@ export function SessionContextTab() {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = view().scroll("context") const s = props.view()?.scroll("context")
if (!s) return if (!s) return
if (el.scrollTop !== s.y) el.scrollTop = s.y if (el.scrollTop !== s.y) el.scrollTop = s.y
@@ -248,13 +220,13 @@ export function SessionContextTab() {
pending = undefined pending = undefined
if (!next) return if (!next) return
view().setScroll("context", next) props.view().setScroll("context", next)
}) })
} }
createEffect( createEffect(
on( on(
() => messages().length, () => props.messages().length,
() => { () => {
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
}, },
@@ -328,7 +300,7 @@ export function SessionContextTab() {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={props.messages()}>
{(message) => ( {(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)} )}
@@ -257,12 +257,27 @@ export function SessionHeader() {
] as const ] as const
}) })
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
const list = apps()
return list.every((app) => exists[app.id] !== undefined)
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
createEffect(() => {
if (platform.platform !== "desktop") return
if (!checksReady()) return
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => { const openDir = (app: OpenApp) => {
const directory = projectDirectory() const directory = projectDirectory()
if (!directory) return if (!directory) return
@@ -304,11 +319,9 @@ export function SessionHeader() {
<Show when={centerMount()}> <Show when={centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<Button <button
type="button" type="button"
variant="ghost" class="hidden md:flex w-[240px] max-w-full min-w-0 h-[24px] pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel transition-colors cursor-default hover:bg-surface-raised-base-hover focus-visible:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")} onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")} aria-label={language.t("session.header.searchFiles")}
> >
@@ -324,7 +337,7 @@ export function SessionHeader() {
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind> <Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
)} )}
</Show> </Show>
</Button> </button>
</Portal> </Portal>
)} )}
</Show> </Show>
@@ -385,7 +398,7 @@ export function SessionHeader() {
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel> <DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup <DropdownMenu.RadioGroup
value={current().id} value={prefs.app}
onChange={(value) => { onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp) setPrefs("app", value as OpenApp)
@@ -451,7 +464,7 @@ export function SessionHeader() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active", "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined }, classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 }, style: { scale: 1 },
}} }}
@@ -524,7 +537,7 @@ export function SessionHeader() {
<IconButton <IconButton
icon={share.state.copied ? "check" : "link"} icon={share.state.copied ? "check" : "link"}
variant="ghost" variant="ghost"
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))} onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare} disabled={share.state.unshare}
aria-label={ aria-label={
@@ -418,7 +418,7 @@ export const SettingsGeneral: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8"> <div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
</div> </div>
@@ -431,7 +431,7 @@ export const SettingsGeneral: Component = () => {
<SoundsSection /> <SoundsSection />
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => { {(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest) const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
@@ -457,7 +457,7 @@ export const SettingsGeneral: Component = () => {
</div> </div>
) )
}} }}
</Show>*/} </Show>
<UpdatesSection /> <UpdatesSection />
@@ -370,7 +370,7 @@ export const SettingsKeybinds: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
@@ -59,7 +59,7 @@ export const SettingsModels: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]"> <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base"> <div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
@@ -177,7 +177,7 @@ export const SettingsPermissions: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]"> <div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p> <p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
@@ -132,7 +132,7 @@ export const SettingsProviders: Component = () => {
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]"> <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2> <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
</div> </div>
+40 -48
View File
@@ -1,21 +1,21 @@
import { Button } from "@opencode-ai/ui/button" import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Switch } from "@opencode-ai/ui/switch"
import { Tabs } from "@opencode-ai/ui/tabs"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row" import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { usePlatform } from "@/context/platform" import { Popover } from "@opencode-ai/ui/popover"
import { useSDK } from "@/context/sdk" import { Tabs } from "@opencode-ai/ui/tabs"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000 const pollMs = 10_000
@@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
} }
const listServersByHealth = ( const listServersByHealth = (
list: ServerConnection.Any[], list: string[],
active: ServerConnection.Key | undefined, active: string | undefined,
status: Record<ServerConnection.Key, ServerHealth | undefined>, status: Record<string, ServerHealth | undefined>,
) => { ) => {
if (!list.length) return list if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const)) const order = new Map(list.map((url, index) => [url, index] as const))
@@ -45,16 +45,16 @@ const listServersByHealth = (
} }
return list.slice().sort((a, b) => { return list.slice().sort((a, b) => {
if (ServerConnection.key(a) === active) return -1 if (a === active) return -1
if (ServerConnection.key(b) === active) return 1 if (b === active) return 1
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0) return (order.get(a) ?? 0) - (order.get(b) ?? 0)
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => { const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
const list = servers() const list = servers()
@@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
const refresh = async () => { const refresh = async () => {
const results: Record<string, ServerHealth> = {} const results: Record<string, ServerHealth> = {}
await Promise.all( await Promise.all(
list.map(async (conn) => { list.map(async (url) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) results[url] = await checkServerHealth(url, fetcher)
}), }),
) )
if (dead) return if (dead) return
@@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
return status return status
} }
const useDefaultServerKey = ( const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => { ) => {
const [url, setUrl] = createSignal<string | undefined>() const [url, setUrl] = createSignal<string | undefined>()
@@ -117,14 +117,7 @@ const useDefaultServerKey = (
}) })
}) })
return { return { url, refresh: () => setTick((value) => value + 1) }
key: () => {
const u = url()
if (!u) return
return ServerConnection.key({ type: "http", http: { url: u } })
},
refresh: () => setTick((value) => value + 1),
}
} }
const useMcpToggle = (input: { const useMcpToggle = (input: {
@@ -170,16 +163,16 @@ export function StatusPopover() {
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.current const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] return [current, ...list.filter((item) => item !== current)]
}) })
const health = useServerHealth(servers, fetcher) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -203,7 +196,7 @@ export function StatusPopover() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active", "rounded-md h-[24px] pr-3 pl-0.5 gap-2 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-hover",
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={ trigger={
@@ -258,9 +251,8 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(s) => { {(url) => {
const key = ServerConnection.key(s) const isBlocked = () => health[url]?.healthy === false
const isBlocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -272,19 +264,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(key) server.setActive(url)
navigate("/") navigate("/")
}} }}
> >
<ServerRow <ServerRow
conn={s} url={url}
status={health[key]} status={health[url]}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ badge={
<Show when={key === defaultServer.key()}> <Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -292,7 +284,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={server.current && key === ServerConnection.key(server.current)}> <Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>
+35 -55
View File
@@ -1,15 +1,14 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { showToast } from "@opencode-ai/ui/toast" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { SerializeAddon } from "@/addons/serialize"
import { matchKeybind, parseKeybind } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { monoFontFamily, useSettings } from "@/context/settings" import { monoFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import { parseKeybind, matchKeybind } from "@/context/command"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
@@ -107,14 +106,8 @@ const useTerminalUiBindings = (input: {
input.container.addEventListener("pointerdown", input.handlePointerDown) input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { input.container.addEventListener("click", input.handleLinkClick, { capture: true })
capture: true, input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
})
input.cleanups.push(() =>
input.container.removeEventListener("click", input.handleLinkClick, {
capture: true,
}),
)
input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur) input.term.textarea?.addEventListener("blur", handleTextareaBlur)
@@ -155,7 +148,6 @@ export const Terminal = (props: TerminalProps) => {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const language = useLanguage() const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
let ws: WebSocket | undefined let ws: WebSocket | undefined
@@ -320,6 +312,8 @@ export const Terminal = (props: TerminalProps) => {
const mod = loaded.mod const mod = loaded.mod
const g = loaded.ghostty const g = loaded.ghostty
const once = { value: false }
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
restore && restore &&
@@ -378,13 +372,7 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal() focusTerminal()
@@ -414,28 +402,20 @@ export const Terminal = (props: TerminalProps) => {
cleanups.push(() => window.removeEventListener("resize", handleResize)) cleanups.push(() => window.removeEventListener("resize", handleResize))
} }
const write = (data: string) =>
new Promise<void>((resolve) => {
if (!output) {
resolve()
return
}
output.push(data)
output.flush(resolve)
})
if (restore && restoreSize) { if (restore && restoreSize) {
await write(restore) t.write(restore, () => {
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize() startResize()
})
} else { } else {
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
if (restore) { if (restore) {
await write(restore) t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
} }
startResize() startResize()
} }
@@ -444,32 +424,40 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp) // console.log("Scroll position:", ydisp)
// }) // })
const once = { value: false }
let closing = false
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory) url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
url.username = server.current?.http.username ?? "" if (window.__OPENCODE__?.serverPassword) {
url.password = server.current?.http.password ?? "" url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url) const socket = new WebSocket(url)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) scheduleSize(t.cols, t.rows)
} }
socket.addEventListener("open", handleOpen) socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen() if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
if (disposed) return if (disposed) return
if (closing) return
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data) const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1)) const json = decoder.decode(bytes.subarray(1))
@@ -491,20 +479,20 @@ export const Terminal = (props: TerminalProps) => {
cursor += data.length cursor += data.length
} }
socket.addEventListener("message", handleMessage) socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
const handleError = (error: Event) => { const handleError = (error: Event) => {
if (disposed) return if (disposed) return
if (closing) return
if (once.value) return if (once.value) return
once.value = true once.value = true
console.error("WebSocket error:", error) console.error("WebSocket error:", error)
local.onConnectError?.(error) local.onConnectError?.(error)
} }
socket.addEventListener("error", handleError) socket.addEventListener("error", handleError)
cleanups.push(() => socket.removeEventListener("error", handleError))
const handleClose = (event: CloseEvent) => { const handleClose = (event: CloseEvent) => {
if (disposed) return if (disposed) return
if (closing) return
// Normal closure (code 1000) means PTY process exited - server event handles cleanup // Normal closure (code 1000) means PTY process exited - server event handles cleanup
// For other codes (network issues, server restart), trigger error handler // For other codes (network issues, server restart), trigger error handler
if (event.code !== 1000) { if (event.code !== 1000) {
@@ -514,15 +502,7 @@ export const Terminal = (props: TerminalProps) => {
} }
} }
socket.addEventListener("close", handleClose) socket.addEventListener("close", handleClose)
cleanups.push(() => socket.removeEventListener("close", handleClose))
cleanups.push(() => {
closing = true
socket.removeEventListener("open", handleOpen)
socket.removeEventListener("message", handleMessage)
socket.removeEventListener("error", handleError)
socket.removeEventListener("close", handleClose)
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
})
} }
void run().catch((err) => { void run().catch((err) => {
+21 -27
View File
@@ -1,9 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { batch, onCleanup } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
@@ -18,10 +17,20 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform() const platform = usePlatform()
const abort = new AbortController() const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => { const eventFetch = (() => {
if (!platform.fetch || !server.current) return if (!platform.fetch) return
try { try {
const url = new URL(server.current.http.url) const url = new URL(server.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch if (url.protocol === "http:" && !loopback) return platform.fetch
} catch { } catch {
@@ -29,13 +38,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})() })()
const currentServer = server.current const eventSdk = createOpencodeClient({
if (!currentServer) throw new Error("No server available") baseUrl: server.url,
const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
server: currentServer.http, headers: eventFetch ? undefined : auth,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
@@ -126,7 +133,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (streamErrorLogged) return if (streamErrorLogged) return
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream error", { console.error("[global-sdk] event stream error", {
url: currentServer.http.url, url: server.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -159,7 +166,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!aborted(error) && !streamErrorLogged) { if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: currentServer.http.url, url: server.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
@@ -193,25 +200,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
flush() flush()
}) })
const sdk = createSdkForServer({ const sdk = createOpencodeClient({
server: server.current.http, baseUrl: server.url,
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { return { url: server.url, client: sdk, event: emitter }
url: currentServer.http.url,
client: sdk,
event: emitter,
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error("Server not available")
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
}, },
}) })
@@ -30,6 +30,7 @@ describe("pickDirectoriesToEvict", () => {
describe("loadRootSessionsWithFallback", () => { describe("loadRootSessionsWithFallback", () => {
test("uses limited roots query when supported", async () => { test("uses limited roots query when supported", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = [] const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({ const result = await loadRootSessionsWithFallback({
directory: "dir", directory: "dir",
@@ -38,15 +39,20 @@ describe("loadRootSessionsWithFallback", () => {
calls.push(query) calls.push(query)
return { data: [] } return { data: [] }
}, },
onFallback: () => {
fallback += 1
},
}) })
expect(result.data).toEqual([]) expect(result.data).toEqual([])
expect(result.limited).toBe(true) expect(result.limited).toBe(true)
expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
expect(fallback).toBe(0)
}) })
test("falls back to full roots query on limited-query failure", async () => { test("falls back to full roots query on limited-query failure", async () => {
const calls: Array<{ directory: string; roots: true; limit?: number }> = [] const calls: Array<{ directory: string; roots: true; limit?: number }> = []
let fallback = 0
const result = await loadRootSessionsWithFallback({ const result = await loadRootSessionsWithFallback({
directory: "dir", directory: "dir",
@@ -56,6 +62,9 @@ describe("loadRootSessionsWithFallback", () => {
if (query.limit) throw new Error("unsupported") if (query.limit) throw new Error("unsupported")
return { data: [] } return { data: [] }
}, },
onFallback: () => {
fallback += 1
},
}) })
expect(result.data).toEqual([]) expect(result.data).toEqual([])
@@ -64,6 +73,7 @@ describe("loadRootSessionsWithFallback", () => {
{ directory: "dir", roots: true, limit: 25 }, { directory: "dir", roots: true, limit: 25 },
{ directory: "dir", roots: true }, { directory: "dir", roots: true },
]) ])
expect(fallback).toBe(1)
}) })
}) })
+61 -40
View File
@@ -1,41 +1,41 @@
import type { import {
Config, type Config,
OpencodeClient, type Path,
Path, type Project,
Project, type ProviderAuthResponse,
ProviderAuthResponse, type ProviderListResponse,
ProviderListResponse, type Todo,
Todo, createOpencodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { createStore, produce, reconcile } from "solid-js/store"
import { getFilename } from "@opencode-ai/util/path" import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import { import {
createContext, createContext,
createEffect, createEffect,
untrack,
getOwner, getOwner,
Match, useContext,
onCleanup, onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Switch, Switch,
untrack, Match,
useContext,
} from "solid-js" } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { InitError } from "../pages/error"
import { useGlobalSDK } from "./global-sdk"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { createChildStoreManager } from "./global-sync/child-store"
import { trimSessions } from "./global-sync/session-trim" import { trimSessions } from "./global-sync/session-trim"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { sanitizeProject } from "./global-sync/utils"
import { usePlatform } from "./platform"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -57,6 +57,14 @@ function errorMessage(error: unknown) {
return "Unknown error" return "Unknown error"
} }
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() { function createGlobalSync() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const platform = usePlatform() const platform = usePlatform()
@@ -64,7 +72,12 @@ function createGlobalSync() {
const owner = getOwner() const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner") if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>() const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
const booting = new Map<string, Promise<void>>() const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>() const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
@@ -99,6 +112,15 @@ function createGlobalSync() {
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" })) setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
} }
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
})
}
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({ const queue = createRefreshQueue({
@@ -109,6 +131,11 @@ function createGlobalSync() {
const children = createChildStoreManager({ const children = createChildStoreManager({
owner, owner,
markStats: updateStats,
incrementEvictions: () => {
stats.evictions += 1
updateStats(Object.keys(children.children).length)
},
isBooting: (directory) => booting.has(directory), isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory), isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => { onBootstrap: (directory) => {
@@ -124,7 +151,9 @@ function createGlobalSync() {
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory) const cached = sdkCache.get(directory)
if (cached) return cached if (cached) return cached
const sdk = globalSDK.createClient({ const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@@ -164,10 +193,7 @@ function createGlobalSync() {
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory) const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) { if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
} }
@@ -180,6 +206,10 @@ function createGlobalSync() {
directory, directory,
limit, limit,
list: (query) => globalSDK.client.session.list(query), list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
updateStats(Object.keys(children.children).length)
},
}) })
.then((x) => { .then((x) => {
const nonArchived = (x.data ?? []) const nonArchived = (x.data ?? [])
@@ -188,17 +218,10 @@ function createGlobalSync() {
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID) const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], { const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
limit,
permission: store.permission,
})
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
count: nonArchived.length,
limit: x.limit,
limited: x.limited,
}),
) )
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit }) sessionMeta.set(directory, { limit })
@@ -308,9 +331,7 @@ function createGlobalSync() {
await bootstrapGlobal({ await bootstrapGlobal({
globalSDK: globalSDK.client, globalSDK: globalSDK.client,
connectErrorTitle: language.t("dialog.server.add.error"), connectErrorTitle: language.t("dialog.server.add.error"),
connectErrorDescription: language.t("error.globalSync.connectFailed", { connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore, setGlobalStore,
}) })
@@ -1,21 +1,21 @@
import type { import {
Config, type Config,
OpencodeClient, type Path,
Path, type PermissionRequest,
PermissionRequest, type Project,
Project, type ProviderAuthResponse,
ProviderAuthResponse, type ProviderListResponse,
ProviderListResponse, type QuestionRequest,
QuestionRequest, type Todo,
Todo, createOpencodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { retry } from "@opencode-ai/util/retry"
import { batch } from "solid-js" import { batch } from "solid-js"
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import { retry } from "@opencode-ai/util/retry"
import { getFilename } from "@opencode-ai/util/path"
import { showToast } from "@opencode-ai/ui/toast"
import { cmp, normalizeProviderList } from "./utils" import { cmp, normalizeProviderList } from "./utils"
import type { State, VcsCache } from "./types"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -31,7 +31,7 @@ type GlobalStore = {
} }
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient globalSDK: ReturnType<typeof createOpencodeClient>
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
@@ -110,7 +110,7 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: OpencodeClient sdk: ReturnType<typeof createOpencodeClient>
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
@@ -17,6 +17,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},
@@ -17,6 +17,8 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
markStats: (activeDirectoryStores: number) => void
incrementEvictions: () => void
isBooting: (directory: string) => boolean isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void onBootstrap: (directory: string) => void
@@ -100,6 +102,7 @@ export function createChildStoreManager(input: {
} }
delete children[directory] delete children[directory]
input.onDispose(directory) input.onDispose(directory)
input.markStats(Object.keys(children).length)
return true return true
} }
@@ -117,6 +120,7 @@ export function createChildStoreManager(input: {
if (list.length === 0) return if (list.length === 0) return
for (const directory of list) { for (const directory of list) {
if (!disposeDirectory(directory)) continue if (!disposeDirectory(directory)) continue
input.incrementEvictions()
} }
} }
@@ -196,6 +200,7 @@ export function createChildStoreManager(input: {
}) })
runWithOwner(input.owner, init) runWithOwner(input.owner, init)
input.markStats(Object.keys(children).length)
} }
mark(directory) mark(directory)
const childStore = children[directory] const childStore = children[directory]
@@ -9,6 +9,7 @@ export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
limited: true, limited: true,
} as const } as const
} catch { } catch {
input.onFallback()
const result = await input.list({ directory: input.directory, roots: true }) const result = await input.list({ directory: input.directory, roots: true })
return { return {
data: result.data, data: result.data,
@@ -119,6 +119,7 @@ export type RootLoadArgs = {
directory: string directory: string
limit: number limit: number
list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
onFallback: () => void
} }
export type RootLoadResult = { export type RootLoadResult = {
+10 -6
View File
@@ -174,10 +174,6 @@ function detectLocale(): Locale {
return "en" return "en"
} }
function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
init: () => { init: () => {
@@ -188,7 +184,15 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}), }),
) )
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() =>
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
)
createEffect(() => {
const current = locale()
if (store.locale === current) return
setStore("locale", current)
})
const dict = createMemo<Dictionary>(() => DICT[locale()]) const dict = createMemo<Dictionary>(() => DICT[locale()])
@@ -209,7 +213,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label, label,
t, t,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", normalizeLocale(next)) setStore("locale", next)
}, },
} }
}, },
+2 -2
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
@@ -58,7 +58,7 @@ export type Platform = {
fetch?: typeof fetch fetch?: typeof fetch
/** Get the configured default server URL (platform-specific) */ /** Get the configured default server URL (platform-specific) */
getDefaultServerUrl?(): Promise<string | null> getDefaultServerUrl?(): Promise<string | null> | string | null
/** Set the default server URL to use on app startup (platform-specific) */ /** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void setDefaultServerUrl?(url: string | null): Promise<void> | void
+7 -6
View File
@@ -1,8 +1,9 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = { type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }> [key in Event["type"]]: Extract<Event, { type: key }>
@@ -11,11 +12,14 @@ type SDKEventMap = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK", name: "SDK",
init: (props: { directory: Accessor<string> }) => { init: (props: { directory: Accessor<string> }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory) const directory = createMemo(props.directory)
const client = createMemo(() => const client = createMemo(() =>
globalSDK.createClient({ createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(), directory: directory(),
throwOnError: true, throwOnError: true,
}), }),
@@ -41,9 +45,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
get url() { get url() {
return globalSDK.url return globalSDK.url
}, },
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
} }
}, },
}) })
+78 -126
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -15,126 +15,92 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "") return withProtocol.replace(/\/+$/, "")
} }
export function serverDisplayName(conn?: ServerConnection.Any) { export function serverDisplayName(url: string) {
if (!conn) return "" if (!url) return ""
if (conn.displayName) return conn.displayName return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(key: ServerConnection.Key) { function projectsKey(url: string) {
if (!key) return "" if (!url) return ""
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0] const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local" if (host === "localhost" || host === "127.0.0.1") return "local"
} return url
export namespace ServerConnection {
type Base = { displayName?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
} }
export const { use: useServer, provider: ServerProvider } = createSimpleContext({ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server", name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => { init: (props: { defaultUrl: string; isSidecar?: boolean }) => {
const platform = usePlatform() const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]), Persist.global("server", ["server.v3"]),
createStore({ createStore({
list: [] as string[], list: [] as string[],
currentSidecarUrl: "",
projects: {} as Record<string, StoredProject[]>, projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>, lastProject: {} as Record<string, string>,
}), }),
) )
const allServers = createMemo((): Array<ServerConnection.Any> => {
const servers = [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
]
const deduped = new Map(servers.map((conn) => [ServerConnection.key(conn), conn]))
return [...deduped.values()]
})
const [state, setState] = createStore({ const [state, setState] = createStore({
active: props.defaultServer, active: "",
healthy: undefined as boolean | undefined, healthy: undefined as boolean | undefined,
}) })
const healthy = () => state.healthy const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) { const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setState("active", url)
})
}
function startHealthPolling(url: string) {
let alive = true let alive = true
let busy = false let busy = false
const run = () => { const run = () => {
if (busy) return if (busy) return
busy = true busy = true
void check(conn) void check(url)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setState("healthy", next) setState("healthy", next)
@@ -152,73 +118,59 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
} }
function setActive(input: ServerConnection.Key) { function setActive(input: string) {
if (state.active !== input) setState("active", input) const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
} }
function add(input: string) { function add(input: string) {
const url = normalizeServerUrl(input) const url = normalizeServerUrl(input)
if (!url) return if (!url) return
return batch(() => { updateServerList(url)
const http: ServerConnection.HttpBase = { url }
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
const conn: ServerConnection.Http = { type: "http", http }
setState("active", ServerConnection.key(conn))
return conn
})
} }
function remove(key: ServerConnection.Key) { function remove(input: string) {
const list = store.list.filter((x) => x !== key) const url = normalizeServerUrl(input)
batch(() => { if (!url) return
setStore("list", list) updateServerList(url, true)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.key({ type: "http", http: { url: next } }) : props.defaultServer)
} }
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
}) })
}
const isReady = createMemo(() => ready() && !!state.active) const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy) const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const current_ = current() const url = state.active
if (!current_) return if (!url) return
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(current_)) onCleanup(startHealthPolling(url))
}) })
const origin = createMemo(() => projectsKey(state.active)) const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? []) const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo( const isLocal = createMemo(() => origin() === "local")
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return (c?.type === "sidecar" && c.variant === "base") || (c?.type === "http" && isLocalHost(c.http.url))
})
return { return {
ready: isReady, ready: isReady,
healthy, healthy,
isLocal, isLocal,
get key() { get url() {
return state.active return state.active
}, },
get name() { get name() {
return serverDisplayName(current()) return serverDisplayName(state.active)
}, },
get list() { get list() {
return allServers() return store.list
},
get current() {
return current()
}, },
setActive, setActive,
add, add,
+3 -16
View File
@@ -1,14 +1,11 @@
// @refresh reload // @refresh reload
import { iife } from "@opencode-ai/util/iife"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -106,26 +103,16 @@ const platform: Platform = {
forward, forward,
restart, restart,
notify, notify,
getDefaultServerUrl: async () => readDefaultServerUrl(), getDefaultServerUrl: readDefaultServerUrl,
setDefaultServerUrl: writeDefaultServerUrl, setDefaultServerUrl: writeDefaultServerUrl,
} }
const defaultUrl = iife(() => {
const lsDefault = readDefaultServerUrl()
if (lsDefault) return lsDefault
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
})
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const server: ServerConnection.Http = { type: "http", http: { url: defaultUrl } }
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders>
<AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} /> <AppInterface />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),
+2 -2
View File
@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق", "command.agent.cycle.reverse.description": "التبديل إلى الوكيل السابق",
"command.model.variant.cycle": "تغيير جهد التفكير", "command.model.variant.cycle": "تغيير جهد التفكير",
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "التبديل إلى وضع Shell",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "التبديل إلى وضع Prompt",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle": "تبديل مساحات العمل",
+2 -2
View File
@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Mudar para o agente anterior", "command.agent.cycle.reverse.description": "Mudar para o agente anterior",
"command.model.variant.cycle": "Alternar nível de raciocínio", "command.model.variant.cycle": "Alternar nível de raciocínio",
"command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Alternar para o modo Shell",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Alternar para o modo Prompt",
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente", "command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
"command.workspace.toggle": "Alternar espaços de trabalho", "command.workspace.toggle": "Alternar espaços de trabalho",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta", "command.agent.cycle.reverse.description": "Prebaci na prethodnog agenta",
"command.model.variant.cycle": "Promijeni nivo razmišljanja", "command.model.variant.cycle": "Promijeni nivo razmišljanja",
"command.model.variant.cycle.description": "Prebaci na sljedeći nivo", "command.model.variant.cycle.description": "Prebaci na sljedeći nivo",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Prebaci na Shell način",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Prebaci na Prompt način",
"command.permissions.autoaccept.enable": "Automatski prihvataj izmjene", "command.permissions.autoaccept.enable": "Automatski prihvataj izmjene",
"command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena", "command.permissions.autoaccept.disable": "Zaustavi automatsko prihvatanje izmjena",
"command.workspace.toggle": "Prikaži/sakrij radne prostore", "command.workspace.toggle": "Prikaži/sakrij radne prostore",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Skift til forrige agent", "command.agent.cycle.reverse.description": "Skift til forrige agent",
"command.model.variant.cycle": "Skift tænkeindsats", "command.model.variant.cycle": "Skift tænkeindsats",
"command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.model.variant.cycle.description": "Skift til næste indsatsniveau",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Skift til shell-tilstand",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Skift til prompt-tilstand",
"command.permissions.autoaccept.enable": "Accepter ændringer automatisk", "command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
"command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
"command.workspace.toggle": "Skift arbejdsområder", "command.workspace.toggle": "Skift arbejdsområder",
+2 -2
View File
@@ -67,8 +67,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln", "command.agent.cycle.reverse.description": "Zum vorherigen Agenten wechseln",
"command.model.variant.cycle": "Denkaufwand wechseln", "command.model.variant.cycle": "Denkaufwand wechseln",
"command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "In den Shell-Modus wechseln",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "In den Prompt-Modus wechseln",
"command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
"command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
"command.workspace.toggle": "Arbeitsbereiche umschalten", "command.workspace.toggle": "Arbeitsbereiche umschalten",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Switch to the previous agent", "command.agent.cycle.reverse.description": "Switch to the previous agent",
"command.model.variant.cycle": "Cycle thinking effort", "command.model.variant.cycle": "Cycle thinking effort",
"command.model.variant.cycle.description": "Switch to the next effort level", "command.model.variant.cycle.description": "Switch to the next effort level",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Switch to shell mode",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Switch to prompt mode",
"command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.enable": "Auto-accept edits",
"command.permissions.autoaccept.disable": "Stop auto-accepting edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits",
"command.workspace.toggle": "Toggle workspaces", "command.workspace.toggle": "Toggle workspaces",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Cambiar al agente anterior", "command.agent.cycle.reverse.description": "Cambiar al agente anterior",
"command.model.variant.cycle": "Alternar esfuerzo de pensamiento", "command.model.variant.cycle": "Alternar esfuerzo de pensamiento",
"command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Cambiar al modo Shell",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Cambiar al modo Prompt",
"command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
"command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
"command.workspace.toggle": "Alternar espacios de trabajo", "command.workspace.toggle": "Alternar espacios de trabajo",
+2 -2
View File
@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Passer à l'agent précédent", "command.agent.cycle.reverse.description": "Passer à l'agent précédent",
"command.model.variant.cycle": "Changer l'effort de réflexion", "command.model.variant.cycle": "Changer l'effort de réflexion",
"command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Passer en mode Shell",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Passer en mode Prompt",
"command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
"command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
"command.workspace.toggle": "Basculer les espaces de travail", "command.workspace.toggle": "Basculer les espaces de travail",
+4 -4
View File
@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "前のエージェントに切り替え", "command.agent.cycle.reverse.description": "前のエージェントに切り替え",
"command.model.variant.cycle": "思考レベルの切り替え", "command.model.variant.cycle": "思考レベルの切り替え",
"command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.model.variant.cycle.description": "次の思考レベルに切り替え",
"command.prompt.mode.shell": "シェル", "command.prompt.mode.shell": "シェルモードに切り替える",
"command.prompt.mode.normal": "プロンプト", "command.prompt.mode.normal": "プロンプトモードに切り替える",
"command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止", "command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え", "command.workspace.toggle": "ワークスペースを切り替え",
@@ -527,8 +527,8 @@ export const dict = {
"settings.tab.general": "一般", "settings.tab.general": "一般",
"settings.tab.shortcuts": "ショートカット", "settings.tab.shortcuts": "ショートカット",
"settings.desktop.section.wsl": "WSL", "settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL連携", "settings.desktop.wsl.title": "WSL統合",
"settings.desktop.wsl.description": "WindowsのWSL環境でOpenCodeサーバーを実行します。", "settings.desktop.wsl.description": "WindowsのWSLでOpenCodeサーバーを実行します。",
"settings.general.section.appearance": "外観", "settings.general.section.appearance": "外観",
"settings.general.section.notifications": "システム通知", "settings.general.section.notifications": "システム通知",
"settings.general.section.updates": "アップデート", "settings.general.section.updates": "アップデート",
+2 -2
View File
@@ -67,8 +67,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "이전 에이전트로 전환", "command.agent.cycle.reverse.description": "이전 에이전트로 전환",
"command.model.variant.cycle": "생각 수준 순환", "command.model.variant.cycle": "생각 수준 순환",
"command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.model.variant.cycle.description": "다음 생각 수준으로 전환",
"command.prompt.mode.shell": "셸", "command.prompt.mode.shell": "셸 모드로 전환",
"command.prompt.mode.normal": "프롬프트", "command.prompt.mode.normal": "프롬프트 모드로 전환",
"command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지", "command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환", "command.workspace.toggle": "작업 공간 전환",
+2 -2
View File
@@ -72,8 +72,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Bytt til forrige agent", "command.agent.cycle.reverse.description": "Bytt til forrige agent",
"command.model.variant.cycle": "Bytt tenkeinnsats", "command.model.variant.cycle": "Bytt tenkeinnsats",
"command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "Bytt til Shell-modus",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Bytt til Prompt-modus",
"command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.enable": "Godta endringer automatisk",
"command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
"command.workspace.toggle": "Veksle arbeidsområder", "command.workspace.toggle": "Veksle arbeidsområder",
+2 -2
View File
@@ -63,8 +63,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta", "command.agent.cycle.reverse.description": "Przełącz na poprzedniego agenta",
"command.model.variant.cycle": "Przełącz wysiłek myślowy", "command.model.variant.cycle": "Przełącz wysiłek myślowy",
"command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
"command.prompt.mode.shell": "Terminal", "command.prompt.mode.shell": "Przełącz na tryb terminala",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "Przełącz na tryb Prompt",
"command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
"command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
"command.workspace.toggle": "Przełącz przestrzenie robocze", "command.workspace.toggle": "Przełącz przestrzenie robocze",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту", "command.agent.cycle.reverse.description": "Переключиться к предыдущему агенту",
"command.model.variant.cycle": "Цикл режимов мышления", "command.model.variant.cycle": "Цикл режимов мышления",
"command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
"command.prompt.mode.shell": "Оболочка", "command.prompt.mode.shell": "Переключиться в режим оболочки",
"command.prompt.mode.normal": "Промпт", "command.prompt.mode.normal": ереключиться в режим промпта",
"command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства", "command.workspace.toggle": "Переключить рабочие пространства",
+2 -2
View File
@@ -69,8 +69,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า", "command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
"command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด", "command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
"command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
"command.prompt.mode.shell": "เชลล์", "command.prompt.mode.shell": "สลับไปยังโหมดเชลล์",
"command.prompt.mode.normal": "พรอมต์", "command.prompt.mode.normal": "สลับไปยังโหมดพรอมต์",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.workspace.toggle": "สลับพื้นที่ทำงาน",
+2 -2
View File
@@ -93,8 +93,8 @@ export const dict = {
"command.model.variant.cycle": "切换思考强度", "command.model.variant.cycle": "切换思考强度",
"command.model.variant.cycle.description": "切换到下一个强度等级", "command.model.variant.cycle.description": "切换到下一个强度等级",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "切换到 Shell 模式",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "切换到 Prompt 模式",
"command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑",
+2 -2
View File
@@ -73,8 +73,8 @@ export const dict = {
"command.agent.cycle.reverse.description": "切換到上一個代理程式", "command.agent.cycle.reverse.description": "切換到上一個代理程式",
"command.model.variant.cycle": "循環思考強度", "command.model.variant.cycle": "循環思考強度",
"command.model.variant.cycle.description": "切換到下一個強度等級", "command.model.variant.cycle.description": "切換到下一個強度等級",
"command.prompt.mode.shell": "Shell", "command.prompt.mode.shell": "切換到 Shell 模式",
"command.prompt.mode.normal": "Prompt", "command.prompt.mode.normal": "切換到 Prompt 模式",
"command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區", "command.workspace.toggle": "切換工作區",
+1 -2
View File
@@ -1,5 +1,4 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"
+34 -42
View File
@@ -51,6 +51,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings" import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project" import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
@@ -81,7 +82,7 @@ export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted( const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]), Persist.global("layout.page", ["layout.page.v1"]),
createStore({ createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } }, lastSession: {} as { [directory: string]: string },
activeProject: undefined as string | undefined, activeProject: undefined as string | undefined,
activeWorkspace: undefined as string | undefined, activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>, workspaceOrder: {} as Record<string, string[]>,
@@ -177,12 +178,7 @@ export default function Layout(props: ParentProps) {
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
const setHoverProject = (value: string | undefined) => { const clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined))
setState("hoverProject", value)
if (value !== undefined) return
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
const setHoverSession = (id: string | undefined) => setState("hoverSession", id) const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const hoverProjectData = createMemo(() => { const hoverProjectData = createMemo(() => {
@@ -193,7 +189,13 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
if (!layout.sidebar.opened()) return if (!layout.sidebar.opened()) return
setHoverProject(undefined) aim.reset()
setState("hoverProject", undefined)
})
createEffect(() => {
if (state.hoverProject !== undefined) return
aim.reset()
}) })
const autoselecting = createMemo(() => { const autoselecting = createMemo(() => {
@@ -224,7 +226,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => { const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
setState("hoverSession", undefined) setState("hoverSession", undefined)
setHoverProject(undefined) setState("hoverProject", undefined)
} }
const navigateWithSidebarReset = (href: string) => { const navigateWithSidebarReset = (href: string) => {
@@ -824,6 +826,14 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next) if (next) prefetchSession(next)
} }
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
} }
@@ -859,6 +869,15 @@ export default function Layout(props: ParentProps) {
if (next) prefetchSession(next) if (next) prefetchSession(next)
} }
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
})
}
navigateToSession(session) navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
return return
@@ -1074,37 +1093,11 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSettings />) dialog.show(() => <DialogSettings />)
} }
function projectRoot(directory: string) {
const project = layout.projects
.list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => root === directory || dirs.includes(directory),
)
if (known) return known[0]
const [child] = globalSync.child(directory, { bootstrap: false })
const id = child.project
if (!id) return directory
const meta = globalSync.data.project.find((item) => item.id === id)
return meta?.worktree ?? directory
}
function navigateToProject(directory: string | undefined) { function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
const root = projectRoot(directory) server.projects.touch(directory)
server.projects.touch(root) const lastSession = store.lastSession[directory]
navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
navigateWithSidebarReset(`/${base64Encode(projectSession.directory)}/session/${projectSession.id}`)
return
}
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
} }
function navigateToSession(session: Session | undefined) { function navigateToSession(session: Session | undefined) {
@@ -1458,8 +1451,7 @@ export default function Layout(props: ParentProps) {
if (!dir || !id) return if (!dir || !id) return
const directory = decode64(dir) const directory = decode64(dir)
if (!directory) return if (!directory) return
const at = Date.now() setStore("lastSession", directory, id)
setStore("lastProjectSession", projectRoot(directory), { directory, id, at })
notification.session.markViewed(id) notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory]) const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) { if (expanded === false) {
@@ -1516,7 +1508,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) { function handleDragStart(event: unknown) {
const id = getDraggableId(event) const id = getDraggableId(event)
if (!id) return if (!id) return
setHoverProject(undefined) setState("hoverProject", undefined)
setStore("activeProject", id) setStore("activeProject", id)
} }
@@ -1950,7 +1942,7 @@ export default function Layout(props: ParentProps) {
if (navLeave.current !== undefined) clearTimeout(navLeave.current) if (navLeave.current !== undefined) clearTimeout(navLeave.current)
navLeave.current = window.setTimeout(() => { navLeave.current = window.setTimeout(() => {
navLeave.current = undefined navLeave.current = undefined
setHoverProject(undefined) setState("hoverProject", undefined)
setState("hoverSession", undefined) setState("hoverSession", undefined)
}, 300) }, 300)
}} }}
@@ -166,7 +166,7 @@ const SessionHoverPreview = (props: {
when={props.hoverReady()} when={props.hoverReady()}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
> >
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full"> <div class="overflow-y-auto max-h-72 h-full">
<MessageNav <MessageNav
messages={props.hoverMessages() ?? []} messages={props.hoverMessages() ?? []}
current={undefined} current={undefined}
+658 -17
View File
@@ -1,61 +1,156 @@
import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } from "solid-js" import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Dialog } from "@opencode-ai/ui/dialog"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { createAutoScroll } from "@opencode-ai/ui/hooks" import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode" import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { SessionHeader, NewSessionView } from "@/components/session" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { createOpenReviewFile } from "@/pages/session/helpers" import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import {
SessionReviewTab,
StickyAddButton,
type DiffStyle,
type SessionReviewTabProps,
} from "@/pages/session/review-tab"
import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanel } from "@/pages/session/terminal-panel"
import { terminalTabLabel } from "@/pages/session/terminal-label"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionCommands } from "@/pages/session/use-session-commands"
import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel" import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
export default function Page() { export default function Page() {
const layout = useLayout() const layout = useLayout()
const local = useLocal() const local = useLocal()
const file = useFile() const file = useFile()
const sync = useSync() const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog() const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const language = useLanguage() const language = useLanguage()
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const questionRequest = createMemo(() => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const blocked = createMemo(() => !!permRequest() || !!questionRequest())
const [ui, setUi] = createStore({ const [ui, setUi] = createStore({
responding: false,
pendingMessage: undefined as string | undefined, pendingMessage: undefined as string | undefined,
scrollGesture: 0, scrollGesture: 0,
autoCreated: false,
scroll: { scroll: {
overflow: false, overflow: false,
bottom: true, bottom: true,
}, },
}) })
const composer = createSessionComposerState() createEffect(
on(
() => permRequest()?.id,
() => setUi("responding", false),
{ defer: true },
),
)
const decide = (response: "once" | "always" | "reject") => {
const perm = permRequest()
if (!perm) return
if (ui.responding) return
setUi("responding", true)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "") const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey)) const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
@@ -98,6 +193,46 @@ export default function Page() {
), ),
) )
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)") const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
@@ -130,6 +265,16 @@ export default function Page() {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
} }
const openTab = (value: string) => {
const next = normalizeTab(value)
tabs().open(next)
const path = file.pathFromTab(next)
if (!path) return
file.load(path)
openReviewPanel()
}
createEffect(() => { createEffect(() => {
const active = tabs().active() const active = tabs().active()
if (!active) return if (!active) return
@@ -178,6 +323,206 @@ export default function Page() {
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -210,6 +555,8 @@ export default function Page() {
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
turnStart: 0, turnStart: 0,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
@@ -268,6 +615,33 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto") scrollToMessage(msgs[targetIndex], "auto")
} }
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const emptyDiffFiles: string[] = []
const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
const diffsReady = createMemo(() => { const diffsReady = createMemo(() => {
const id = params.id const id = params.id
if (!id) return true if (!id) return true
@@ -275,6 +649,7 @@ export default function Page() {
return sync.data.session_diff[id] !== undefined return sync.data.session_diff[id] !== undefined
}) })
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined let promptDock: HTMLDivElement | undefined
let dockHeight = 0 let dockHeight = 0
@@ -304,6 +679,43 @@ export default function Page() {
void sync.session.todo(id) void sync.session.todo(id)
}) })
createEffect(() => {
if (!view().terminal.opened()) {
setUi("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return
terminal.new()
setUi("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !view().terminal.opened()) return
// Immediately remove focus
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect( createEffect(
on( on(
() => visibleUserMessages().at(-1)?.id, () => visibleUserMessages().at(-1)?.id,
@@ -316,12 +728,20 @@ export default function Page() {
), ),
) )
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect( createEffect(
on( on(
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("changes", "session") setStore("changes", "session")
setUi("autoCreated", false)
}, },
{ defer: true }, { defer: true },
), ),
@@ -348,6 +768,11 @@ export default function Page() {
return lines.slice(0, 2).join("\n") return lines.slice(0, 2).join("\n")
} }
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: { const addCommentToContext = (input: {
file: string file: string
selection: SelectedLineRange selection: SelectedLineRange
@@ -397,11 +822,58 @@ export default function Page() {
} }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
if (composer.blocked()) return if (blocked()) return
inputRef?.focus() inputRef?.focus()
} }
} }
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeTerminalDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
}
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() => const openedTabs = createMemo(() =>
tabs() tabs()
@@ -439,8 +911,29 @@ export default function Page() {
const focusInput = () => inputRef?.focus() const focusInput = () => inputRef?.focus()
useSessionCommands({ useSessionCommands({
command,
dialog,
file,
language,
local,
permission,
prompt,
sdk,
sync,
terminal,
layout,
params,
navigate,
tabs,
view,
info,
status,
userMessages,
visibleUserMessages,
showAllFiles,
navigateMessageByOffset, navigateMessageByOffset,
setActiveMessage, setActiveMessage,
addSelectionToContext,
focusInput, focusInput,
}) })
@@ -578,6 +1071,11 @@ export default function Page() {
), ),
) )
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
setFileTreeTab(value)
}
const reviewDiffId = (path: string) => { const reviewDiffId = (path: string) => {
const sum = checksum(path) const sum = checksum(path)
if (!sum) return if (!sum) return
@@ -673,6 +1171,12 @@ export default function Page() {
return "empty" return "empty"
}) })
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
createEffect(() => { createEffect(() => {
if (!layout.ready()) return if (!layout.ready()) return
if (tabs().active()) return if (tabs().active()) return
@@ -977,10 +1481,62 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume, consumePendingMessage: layout.pendingMessage.consume,
}) })
onMount(() => { createEffect(() => {
document.addEventListener("keydown", handleKeyDown) document.addEventListener("keydown", handleKeyDown)
}) })
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
if (!terminal.ready()) return
language.locale()
touch(
handoff.terminal,
params.dir!,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
onCleanup(() => { onCleanup(() => {
cancelTurnBackfill() cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown) document.removeEventListener("keydown", handleKeyDown)
@@ -999,6 +1555,7 @@ export default function Page() {
reviewCount={reviewCount()} reviewCount={reviewCount()}
onSession={() => setStore("mobileTab", "session")} onSession={() => setStore("mobileTab", "session")}
onChanges={() => setStore("mobileTab", "changes")} onChanges={() => setStore("mobileTab", "changes")}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
/> />
{/* Session panel */} {/* Session panel */}
@@ -1038,7 +1595,27 @@ export default function Page() {
isDesktop={isDesktop()} isDesktop={isDesktop()}
onScrollSpyScroll={scrollSpy.onScroll} onScrollSpyScroll={scrollSpy.onScroll}
onAutoScrollInteraction={autoScroll.handleInteraction} onAutoScrollInteraction={autoScroll.handleInteraction}
showHeader={!!(info()?.title || info()?.parentID)}
centered={centered()} centered={centered()}
title={info()?.title}
parentID={info()?.parentID}
openTitleEditor={openTitleEditor}
closeTitleEditor={closeTitleEditor}
saveTitleEditor={saveTitleEditor}
titleRef={(el) => {
titleRef = el
}}
titleState={title}
onTitleDraft={(value) => setTitle("draft", value)}
onTitleMenuOpen={(open) => setTitle("menuOpen", open)}
onTitlePendingRename={(value) => setTitle("pendingRename", value)}
onNavigateParent={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
sessionID={params.id!}
onArchiveSession={(sessionID) => void archiveSession(sessionID)}
onDeleteSession={(sessionID) => dialog.show(() => <DialogDeleteSession sessionID={sessionID} />)}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
setContentRef={(el) => { setContentRef={(el) => {
content = el content = el
autoScroll.contentRef(el) autoScroll.contentRef(el)
@@ -1060,6 +1637,11 @@ export default function Page() {
anchor={anchor} anchor={anchor}
onRegisterMessage={scrollSpy.register} onRegisterMessage={scrollSpy.register}
onUnregisterMessage={scrollSpy.unregister} onUnregisterMessage={scrollSpy.unregister}
onFirstTurnMount={() => {
const id = params.id
if (!id) return
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id} lastUserMessageID={lastUserMessage()?.id}
/> />
</Show> </Show>
@@ -1086,9 +1668,17 @@ export default function Page() {
</Switch> </Switch>
</div> </div>
<SessionComposerRegion <SessionPromptDock
state={composer}
centered={centered()} centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
responding={ui.responding}
onDecide={decide}
inputRef={(el) => { inputRef={(el) => {
inputRef = el inputRef = el
}} }}
@@ -1098,10 +1688,7 @@ export default function Page() {
comments.clear() comments.clear()
resumeScroll() resumeScroll()
}} }}
onResponseSubmit={resumeScroll} setPromptDockRef={(el) => (promptDock = el)}
setPromptDockRef={(el) => {
promptDock = el
}}
/> />
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
@@ -1115,10 +1702,64 @@ export default function Page() {
</Show> </Show>
</div> </div>
<SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} /> <SessionSidePanel
open={desktopSidePanelOpen()}
reviewOpen={desktopReviewOpen()}
language={language}
layout={layout}
command={command}
dialog={dialog}
file={file}
comments={comments}
hasReview={hasReview()}
reviewCount={reviewCount()}
reviewTab={reviewTab()}
contextOpen={contextOpen}
openedTabs={openedTabs}
activeTab={activeTab}
activeFileTab={activeFileTab}
tabs={tabs}
openTab={openTab}
showAllFiles={showAllFiles}
reviewPanel={reviewPanel}
vm={{
messages,
visibleUserMessages,
view,
info,
}}
handoffFiles={() => handoff.session.get(sessionKey())?.files}
codeComponent={codeComponent}
addCommentToContext={addCommentToContext}
activeDraggable={() => store.activeDraggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
fileTreeTab={fileTreeTab}
setFileTreeTabValue={setFileTreeTabValue}
diffsReady={diffsReady()}
diffFiles={diffFiles()}
kinds={kinds()}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
/>
</div> </div>
<TerminalPanel /> <TerminalPanel
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}
terminal={terminal}
language={language}
command={command}
handoff={() => handoff.terminal.get(params.dir!) ?? []}
activeTerminalDraggable={() => store.activeTerminalDraggable}
handleTerminalDragStart={handleTerminalDragStart}
handleTerminalDragOver={handleTerminalDragOver}
handleTerminalDragEnd={handleTerminalDragEnd}
onCloseTab={() => setUi("autoCreated", false)}
/>
</div> </div>
) )
} }
@@ -1,3 +0,0 @@
export { SessionComposerRegion } from "./session-composer-region"
export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state"
export type { SessionComposerState } from "./session-composer-state"
@@ -1,128 +0,0 @@
import { Show, createEffect, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
export function SessionComposerRegion(props: {
state: SessionComposerState
centered: boolean
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const params = useParams()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
{(request) => (
<div>
<SessionQuestionDock request={request} onSubmit={props.onResponseSubmit} />
</div>
)}
</Show>
<Show when={props.state.permissionRequest()} keyed>
{(request) => (
<div>
<SessionPermissionDock
request={request}
responding={props.state.permissionResponding()}
onDecide={(response) => {
props.onResponseSubmit()
props.state.decide(response)
}}
/>
</div>
)}
</Show>
<Show when={!props.state.blocked()}>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoffPrompt() || language.t("prompt.loading")}
</div>
}
>
<Show when={props.state.dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !props.state.closing(),
"max-h-0 pointer-events-none": props.state.closing(),
"opacity-0 translate-y-9": props.state.closing() || props.state.opening(),
"opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(),
}}
>
<SessionTodoDock
todos={props.state.todos()}
title={language.t("session.todo.title")}
collapseLabel={language.t("session.todo.collapse")}
expandLabel={language.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": props.state.dock() && !props.state.closing(),
"mt-0": !props.state.dock() || props.state.closing(),
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>
</div>
)
}
@@ -1,158 +0,0 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function createSessionComposerBlocked() {
const params = useParams()
const sync = useSync()
return createMemo(() => {
const id = params.id
if (!id) return false
return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0]
})
}
export function createSessionComposerState() {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const language = useLanguage()
const questionRequest = createMemo((): QuestionRequest | undefined => {
const id = params.id
if (!id) return
return sync.data.question[id]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const id = params.id
if (!id) return
return sync.data.permission[id]?.[0]
})
const blocked = createSessionComposerBlocked()
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const [store, setStore] = createStore({
responding: undefined as string | undefined,
dock: todos().length > 0,
closing: false,
opening: false,
})
const permissionResponding = createMemo(() => {
const perm = permissionRequest()
if (!perm) return false
return store.responding === perm.id
})
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (store.responding === perm.id) return
setStore("responding", perm.id)
sdk.client.permission
.respond({ sessionID: perm.sessionID, permissionID: perm.id, response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
})
.finally(() => {
setStore("responding", (id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
let timer: number | undefined
let raf: number | undefined
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setStore({ dock: false, closing: false })
timer = undefined
}, 400)
}
createEffect(
on(
() => [todos().length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setStore({ dock: false, closing: false, opening: false })
return
}
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const hidden = !store.dock || store.closing
setStore({ dock: true, closing: false })
if (hidden) {
setStore("opening", true)
raf = requestAnimationFrame(() => {
setStore("opening", false)
raf = undefined
})
return
}
setStore("opening", false)
return
}
if (prev && prev[1]) {
if (store.closing && !timer) scheduleClose()
return
}
setStore({ dock: true, opening: false, closing: true })
scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return {
blocked,
questionRequest,
permissionRequest,
permissionResponding,
decide,
todos,
dock: () => store.dock,
closing: () => store.closing,
opening: () => store.opening,
}
}
export type SessionComposerState = ReturnType<typeof createSessionComposerState>
@@ -1,74 +0,0 @@
import { For, Show } from "solid-js"
import type { PermissionRequest } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
export function SessionPermissionDock(props: {
request: PermissionRequest
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
}) {
const language = useLanguage()
const toolDescription = () => {
const key = `settings.permissions.tool.${props.request.permission}.description`
const value = language.t(key as Parameters<typeof language.t>[0])
if (value === key) return ""
return value
}
return (
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
{language.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{language.t("ui.permission.allowAlways")}
</Button>
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
{language.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={props.request.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={props.request.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
)
}
+61 -96
View File
@@ -1,8 +1,6 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { sampledChecksum } from "@opencode-ai/util/encode" import { sampledChecksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@@ -10,11 +8,9 @@ import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { getSessionHandoff } from "@/pages/session/handoff"
const formatCommentLabel = (range: SelectedLineRange) => { const formatCommentLabel = (range: SelectedLineRange) => {
const start = Math.min(range.start, range.end) const start = Math.min(range.start, range.end)
@@ -23,29 +19,34 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}` return `lines ${start}-${end}`
} }
export function FileTabContent(props: { tab: string }) { export function FileTabContent(props: {
const params = useParams() tab: string
const layout = useLayout() activeTab: () => string
const file = useFile() tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
const comments = useComments() view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
const language = useLanguage() handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
const prompt = usePrompt() file: ReturnType<typeof useFile>
const codeComponent = useCodeComponent() comments: ReturnType<typeof useComments>
language: ReturnType<typeof useLanguage>
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) codeComponent: NonNullable<ValidComponent>
const tabs = createMemo(() => layout.tabs(sessionKey)) addCommentToContext: (input: {
const view = createMemo(() => layout.view(sessionKey)) file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
}) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined let scrollFrame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = [] let codeScroll: HTMLElement[] = []
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => props.file.pathFromTab(props.tab))
const state = createMemo(() => { const state = createMemo(() => {
const p = path() const p = path()
if (!p) return if (!p) return
return file.get(p) return props.file.get(p)
}) })
const contents = createMemo(() => state()?.content?.content ?? "") const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents())) const cacheKey = createMemo(() => sampledChecksum(contents()))
@@ -81,7 +82,7 @@ export function FileTabContent(props: { tab: string }) {
svgToast.shown = true svgToast.shown = true
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("toast.file.loadFailed.title"), title: props.language.t("toast.file.loadFailed.title"),
}) })
}) })
const svgPreviewUrl = createMemo(() => { const svgPreviewUrl = createMemo(() => {
@@ -99,57 +100,16 @@ export function FileTabContent(props: { tab: string }) {
const selectedLines = createMemo(() => { const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null if (props.file.ready()) return props.file.selectedLines(p) ?? null
return getSessionHandoff(sessionKey())?.files[p] ?? null return props.handoffFiles()?.[p] ?? null
}) })
const selectionPreview = (source: string, selection: FileSelection) => {
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = source.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const saved = comments.add({
file: input.file,
selection: input.selection,
comment: input.comment,
})
prompt.context.add({
type: "file",
path: input.file,
selection,
comment: input.comment,
commentID: saved.id,
commentOrigin: input.origin,
preview,
})
}
let wrap: HTMLDivElement | undefined let wrap: HTMLDivElement | undefined
const fileComments = createMemo(() => { const fileComments = createMemo(() => {
const p = path() const p = path()
if (!p) return [] if (!p) return []
return comments.list(p) return props.comments.list(p)
}) })
const commentLayout = createMemo(() => { const commentLayout = createMemo(() => {
@@ -168,13 +128,6 @@ export function FileTabContent(props: { tab: string }) {
draftTop: undefined as number | undefined, draftTop: undefined as number | undefined,
}) })
const setCommenting = (range: SelectedLineRange | null) => {
setNote("commenting", range)
scheduleComments()
if (!range) return
setNote("draft", "")
}
const getRoot = () => { const getRoot = () => {
const el = wrap const el = wrap
if (!el) return if (!el) return
@@ -268,19 +221,26 @@ export function FileTabContent(props: { tab: string }) {
}) })
createEffect(() => { createEffect(() => {
const focus = comments.focus() const range = note.commenting
scheduleComments()
if (!range) return
setNote("draft", "")
})
createEffect(() => {
const focus = props.comments.focus()
const p = path() const p = path()
if (!focus || !p) return if (!focus || !p) return
if (focus.file !== p) return if (focus.file !== p) return
if (tabs().active() !== props.tab) return if (props.activeTab() !== props.tab) return
const target = fileComments().find((comment) => comment.id === focus.id) const target = fileComments().find((comment) => comment.id === focus.id)
if (!target) return if (!target) return
setNote("openedComment", target.id) setNote("openedComment", target.id)
setCommenting(null) setNote("commenting", null)
file.setSelectedLines(p, target.selection) props.file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => comments.clearFocus()) requestAnimationFrame(() => props.comments.clearFocus())
}) })
const getCodeScroll = () => { const getCodeScroll = () => {
@@ -309,7 +269,7 @@ export function FileTabContent(props: { tab: string }) {
pending = undefined pending = undefined
if (!out) return if (!out) return
view().setScroll(props.tab, out) props.view().setScroll(props.tab, out)
}) })
} }
@@ -345,7 +305,7 @@ export function FileTabContent(props: { tab: string }) {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = view().scroll(props.tab) const s = props.view()?.scroll(props.tab)
if (!s) return if (!s) return
syncCodeScroll() syncCodeScroll()
@@ -383,7 +343,7 @@ export function FileTabContent(props: { tab: string }) {
createEffect( createEffect(
on( on(
() => file.ready(), () => props.file.ready(),
(ready) => { (ready) => {
if (!ready) return if (!ready) return
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
@@ -394,7 +354,7 @@ export function FileTabContent(props: { tab: string }) {
createEffect( createEffect(
on( on(
() => tabs().active() === props.tab, () => props.tabs().active() === props.tab,
(active) => { (active) => {
if (!active) return if (!active) return
if (!state()?.loaded) return if (!state()?.loaded) return
@@ -421,7 +381,7 @@ export function FileTabContent(props: { tab: string }) {
class={`relative overflow-hidden ${wrapperClass}`} class={`relative overflow-hidden ${wrapperClass}`}
> >
<Dynamic <Dynamic
component={codeComponent} component={props.codeComponent}
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: source, contents: source,
@@ -437,17 +397,17 @@ export function FileTabContent(props: { tab: string }) {
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() const p = path()
if (!p) return if (!p) return
file.setSelectedLines(p, range) props.file.setSelectedLines(p, range)
if (!range) setCommenting(null) if (!range) setNote("commenting", null)
}} }}
onLineSelectionEnd={(range: SelectedLineRange | null) => { onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) { if (!range) {
setCommenting(null) setNote("commenting", null)
return return
} }
setNote("openedComment", null) setNote("openedComment", null)
setCommenting(range) setNote("commenting", range)
}} }}
overflow="scroll" overflow="scroll"
class="select-text" class="select-text"
@@ -463,14 +423,14 @@ export function FileTabContent(props: { tab: string }) {
onMouseEnter={() => { onMouseEnter={() => {
const p = path() const p = path()
if (!p) return if (!p) return
file.setSelectedLines(p, comment.selection) props.file.setSelectedLines(p, comment.selection)
}} }}
onClick={() => { onClick={() => {
const p = path() const p = path()
if (!p) return if (!p) return
setCommenting(null) setNote("commenting", null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
file.setSelectedLines(p, comment.selection) props.file.setSelectedLines(p, comment.selection)
}} }}
/> />
)} )}
@@ -483,12 +443,17 @@ export function FileTabContent(props: { tab: string }) {
value={note.draft} value={note.draft}
selection={formatCommentLabel(range())} selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)} onInput={(value) => setNote("draft", value)}
onCancel={() => setCommenting(null)} onCancel={() => setNote("commenting", null)}
onSubmit={(value) => { onSubmit={(value) => {
const p = path() const p = path()
if (!p) return if (!p) return
addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" }) props.addCommentToContext({
setCommenting(null) file: p,
selection: range(),
comment: value,
origin: "file",
})
setNote("commenting", null)
}} }}
onPopoverFocusOut={(e: FocusEvent) => { onPopoverFocusOut={(e: FocusEvent) => {
const current = e.currentTarget as HTMLDivElement const current = e.currentTarget as HTMLDivElement
@@ -497,7 +462,7 @@ export function FileTabContent(props: { tab: string }) {
setTimeout(() => { setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) { if (!document.activeElement || !current.contains(document.activeElement)) {
setCommenting(null) setNote("commenting", null)
} }
}, 0) }, 0)
}} }}
@@ -544,13 +509,13 @@ export function FileTabContent(props: { tab: string }) {
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="flex flex-col gap-2 max-w-md"> <div class="flex flex-col gap-2 max-w-md">
<div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div> <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
<div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div> <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
</div> </div>
</div> </div>
</Match> </Match>
<Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match> <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
</Match> </Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match> <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch> </Switch>
-36
View File
@@ -1,36 +0,0 @@
import type { SelectedLineRange } from "@/context/file"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const MAX = 40
const store = {
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { prompt: "", files: {} }
touch(store.session, key, { ...prev, ...patch })
}
export const getSessionHandoff = (key: string) => store.session.get(key)
export const setTerminalHandoff = (key: string, value: string[]) => {
touch(store.terminal, key, value)
}
export const getTerminalHandoff = (key: string) => store.terminal.get(key)
+1 -32
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers" import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => { test("opens and loads selected review file", () => {
@@ -20,37 +20,6 @@ describe("createOpenReviewFile", () => {
}) })
}) })
describe("createOpenSessionFileTab", () => {
test("activates the opened file tab", () => {
const calls: string[] = []
const openTab = createOpenSessionFileTab({
normalizeTab: (value) => {
calls.push(`normalize:${value}`)
return `file://${value}`
},
openTab: (tab) => calls.push(`open:${tab}`),
pathFromTab: (tab) => {
calls.push(`path:${tab}`)
return tab.slice("file://".length)
},
loadFile: (path) => calls.push(`load:${path}`),
openReviewPanel: () => calls.push("review"),
setActive: (tab) => calls.push(`active:${tab}`),
})
openTab("src/a.ts")
expect(calls).toEqual([
"normalize:src/a.ts",
"open:file://src/a.ts",
"path:file://src/a.ts",
"load:src/a.ts",
"review",
"active:file://src/a.ts",
])
})
})
describe("focusTerminalById", () => { describe("focusTerminalById", () => {
test("focuses textarea when present", () => { test("focuses textarea when present", () => {
document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>` document.body.innerHTML = `<div id="terminal-wrapper-one"><div data-component="terminal"><textarea></textarea></div></div>`
-21
View File
@@ -35,27 +35,6 @@ export const createOpenReviewFile = (input: {
} }
} }
export const createOpenSessionFileTab = (input: {
normalizeTab: (tab: string) => string
openTab: (tab: string) => void
pathFromTab: (tab: string) => string | undefined
loadFile: (path: string) => void
openReviewPanel: () => void
setActive: (tab: string) => void
}) => {
return (value: string) => {
const next = input.normalizeTab(value)
input.openTab(next)
const path = input.pathFromTab(next)
if (!path) return
input.loadFile(path)
input.openReviewPanel()
input.setActive(next)
}
}
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => { export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from) const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to) const toIndex = tabs.indexOf(to)
@@ -1,21 +1,13 @@
import { For, createEffect, createMemo, on, onCleanup, Show, type JSX } from "solid-js" import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionTurn } from "@opencode-ai/ui/session-turn"
import type { UserMessage } from "@opencode-ai/sdk/v2" import type { UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined const current = target instanceof Element ? target : undefined
@@ -61,7 +53,29 @@ export function MessageTimeline(props: {
isDesktop: boolean isDesktop: boolean
onScrollSpyScroll: () => void onScrollSpyScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void onAutoScrollInteraction: (event: MouseEvent) => void
showHeader: boolean
centered: boolean centered: boolean
title?: string
parentID?: string
openTitleEditor: () => void
closeTitleEditor: () => void
saveTitleEditor: () => void | Promise<void>
titleRef: (el: HTMLInputElement) => void
titleState: {
draft: string
editing: boolean
saving: boolean
menuOpen: boolean
pendingRename: boolean
}
onTitleDraft: (value: string) => void
onTitleMenuOpen: (open: boolean) => void
onTitlePendingRename: (value: boolean) => void
onNavigateParent: () => void
sessionID: string
onArchiveSession: (sessionID: string) => void
onDeleteSession: (sessionID: string) => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
setContentRef: (el: HTMLDivElement) => void setContentRef: (el: HTMLDivElement) => void
turnStart: number turnStart: number
onRenderEarlier: () => void onRenderEarlier: () => void
@@ -72,234 +86,11 @@ export function MessageTimeline(props: {
anchor: (id: string) => string anchor: (id: string) => string
onRegisterMessage: (el: HTMLDivElement, id: string) => void onRegisterMessage: (el: HTMLDivElement, id: string) => void
onUnregisterMessage: (id: string) => void onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string lastUserMessageID?: string
}) { }) {
let touchGesture: number | undefined let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const info = createMemo(() => {
const id = sessionID()
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return ( return (
<Show <Show
when={!props.mobileChanges} when={!props.mobileChanges}
@@ -366,12 +157,9 @@ export function MessageTimeline(props: {
}} }}
onClick={props.onAutoScrollInteraction} onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller" class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{ style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
> >
<Show when={showHeader()}> <Show when={props.showHeader}>
<div <div
classList={{ classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
@@ -383,96 +171,92 @@ export function MessageTimeline(props: {
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}> <Show when={props.parentID}>
<IconButton <IconButton
tabIndex={-1} tabIndex={-1}
icon="arrow-left" icon="arrow-left"
variant="ghost" variant="ghost"
onClick={navigateParent} onClick={props.onNavigateParent}
aria-label={language.t("common.goBack")} aria-label={props.t("common.goBack")}
/> />
</Show> </Show>
<Show when={titleValue() || title.editing}> <Show when={props.title || props.titleState.editing}>
<Show <Show
when={title.editing} when={props.titleState.editing}
fallback={ fallback={
<h1 <h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2" class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor} onDblClick={props.openTitleEditor}
> >
{titleValue()} {props.title}
</h1> </h1>
} }
> >
<InlineInput <InlineInput
ref={(el) => { ref={props.titleRef}
titleRef = el value={props.titleState.draft}
}} disabled={props.titleState.saving}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
void saveTitleEditor() void props.saveTitleEditor()
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
closeTitleEditor() props.closeTitleEditor()
} }
}} }}
onBlur={closeTitleEditor} onBlur={props.closeTitleEditor}
/> />
</Show> </Show>
</Show> </Show>
</div> </div>
<Show when={sessionID()}> <Show when={props.sessionID}>
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center gap-3"> <div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" /> <SessionContextUsage placement="bottom" />
<DropdownMenu <DropdownMenu
gutter={4} gutter={4}
placement="bottom-end" placement="bottom-end"
open={title.menuOpen} open={props.titleState.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)} onOpenChange={props.onTitleMenuOpen}
> >
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active" class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")} aria-label={props.t("common.moreOptions")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
style={{ "min-width": "104px" }} style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!title.pendingRename) return if (!props.titleState.pendingRename) return
event.preventDefault() event.preventDefault()
setTitle("pendingRename", false) props.onTitlePendingRename(false)
openTitleEditor() props.openTitleEditor()
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
setTitle("pendingRename", true) props.onTitlePendingRename(true)
setTitle("menuOpen", false) props.onTitleMenuOpen(false)
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}> <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)} <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -498,7 +282,7 @@ export function MessageTimeline(props: {
<Show when={props.turnStart > 0}> <Show when={props.turnStart > 0}>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}> <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
{language.t("session.messages.renderEarlier")} {props.t("session.messages.renderEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
@@ -512,13 +296,18 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier} onClick={props.onLoadEarlier}
> >
{props.historyLoading {props.historyLoading
? language.t("session.messages.loadingEarlier") ? props.t("session.messages.loadingEarlier")
: language.t("session.messages.loadEarlier")} : props.t("session.messages.loadEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
<For each={props.renderedUserMessages}> <For each={props.renderedUserMessages}>
{(message) => ( {(message) => {
if (import.meta.env.DEV && props.onFirstTurnMount) {
onMount(() => props.onFirstTurnMount?.())
}
return (
<div <div
id={props.anchor(message.id)} id={props.anchor(message.id)}
data-message-id={message.id} data-message-id={message.id}
@@ -532,17 +321,18 @@ export function MessageTimeline(props: {
}} }}
> >
<SessionTurn <SessionTurn
sessionID={sessionID() ?? ""} sessionID={props.sessionID}
messageID={message.id} messageID={message.id}
lastUserMessageID={props.lastUserMessageID} lastUserMessageID={props.lastUserMessageID}
classes={{ classes={{
root: "min-w-0 w-full relative", root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible", content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-5", container: "w-full px-4 md:px-6",
}} }}
/> />
</div> </div>
)} )
}}
</For> </For>
</div> </div>
</div> </div>
@@ -144,8 +144,8 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onOpenChange={props.view().review.setOpen} onOpenChange={props.view().review.setOpen}
classes={{ classes={{
root: props.classes?.root ?? "pb-6", root: props.classes?.root ?? "pb-6",
header: props.classes?.header ?? "px-3", header: props.classes?.header ?? "px-6",
container: props.classes?.container ?? "px-3", container: props.classes?.container ?? "px-6",
}} }}
diffs={props.diffs()} diffs={props.diffs()}
diffStyle={props.diffStyle} diffStyle={props.diffStyle}
@@ -1,6 +1,5 @@
import { Show } from "solid-js" import { Show } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useLanguage } from "@/context/language"
export function SessionMobileTabs(props: { export function SessionMobileTabs(props: {
open: boolean open: boolean
@@ -9,9 +8,8 @@ export function SessionMobileTabs(props: {
reviewCount: number reviewCount: number
onSession: () => void onSession: () => void
onChanges: () => void onChanges: () => void
t: (key: string, vars?: Record<string, string | number | boolean>) => string
}) { }) {
const language = useLanguage()
return ( return (
<Show when={props.open}> <Show when={props.open}>
<Tabs value={props.mobileTab} class="h-auto"> <Tabs value={props.mobileTab} class="h-auto">
@@ -22,7 +20,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={props.onSession} onClick={props.onSession}
> >
{language.t("session.tab.session")} {props.t("session.tab.session")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
value="changes" value="changes"
@@ -31,8 +29,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges} onClick={props.onChanges}
> >
{props.hasReview {props.hasReview
? language.t("session.review.filesChanged", { count: props.reviewCount }) ? props.t("session.review.filesChanged", { count: props.reviewCount })
: language.t("session.review.change.other")} : props.t("session.review.change.other")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
@@ -0,0 +1,249 @@
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
export function SessionPromptDock(props: {
centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
responding: boolean
onDecide: (response: "once" | "always" | "reject") => void
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeReset: () => void
onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void
}) {
const done = createMemo(
() =>
props.todos.length > 0 && props.todos.every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(props.todos.length > 0)
const [closing, setClosing] = createSignal(false)
const [opening, setOpening] = createSignal(false)
let timer: number | undefined
let raf: number | undefined
const scheduleClose = () => {
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setDock(false)
setClosing(false)
timer = undefined
}, 400)
}
createEffect(
on(
() => [props.todos.length, done()] as const,
([count, complete], prev) => {
if (raf) cancelAnimationFrame(raf)
raf = undefined
if (count === 0) {
if (timer) window.clearTimeout(timer)
timer = undefined
setDock(false)
setClosing(false)
setOpening(false)
return
}
if (!complete) {
if (timer) window.clearTimeout(timer)
timer = undefined
const wasHidden = !dock() || closing()
setDock(true)
setClosing(false)
if (wasHidden) {
setOpening(true)
raf = requestAnimationFrame(() => {
setOpening(false)
raf = undefined
})
return
}
setOpening(false)
return
}
if (prev && prev[1]) {
if (closing() && !timer) scheduleClose()
return
}
setDock(true)
setOpening(false)
setClosing(true)
scheduleClose()
},
),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
onCleanup(() => {
if (!raf) return
cancelAnimationFrame(raf)
})
return (
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
class="shrink-0 w-full pb-4 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-4 pointer-events-auto": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.questionRequest()} keyed>
{(req) => {
return (
<div>
<QuestionDock request={req} />
</div>
)
}}
</Show>
<Show when={props.permissionRequest()} keyed>
{(perm) => {
const toolDescription = () => {
const key = `settings.permissions.tool.${perm.permission}.description`
const value = props.t(key)
if (value === key) return ""
return value
}
return (
<div>
<DockPrompt
kind="permission"
header={
<div data-slot="permission-row" data-variant="header">
<span data-slot="permission-icon">
<Icon name="warning" size="normal" />
</span>
<div data-slot="permission-header-title">{props.t("notification.permission.title")}</div>
</div>
}
footer={
<>
<div />
<div data-slot="permission-footer-actions">
<Button
variant="ghost"
size="normal"
onClick={() => props.onDecide("reject")}
disabled={props.responding}
>
{props.t("ui.permission.deny")}
</Button>
<Button
variant="secondary"
size="normal"
onClick={() => props.onDecide("always")}
disabled={props.responding}
>
{props.t("ui.permission.allowAlways")}
</Button>
<Button
variant="primary"
size="normal"
onClick={() => props.onDecide("once")}
disabled={props.responding}
>
{props.t("ui.permission.allowOnce")}
</Button>
</div>
</>
}
>
<Show when={toolDescription()}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-hint">{toolDescription()}</div>
</div>
</Show>
<Show when={perm.patterns.length > 0}>
<div data-slot="permission-row">
<span data-slot="permission-spacer" aria-hidden="true" />
<div data-slot="permission-patterns">
<For each={perm.patterns}>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
</For>
</div>
</div>
</Show>
</DockPrompt>
</div>
)
}}
</Show>
<Show when={!props.blocked}>
<Show
when={props.promptReady}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{props.handoffPrompt || props.t("prompt.loading")}
</div>
}
>
<Show when={dock()}>
<div
classList={{
"transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
"max-h-[320px]": !closing(),
"max-h-0 pointer-events-none": closing(),
"opacity-0 translate-y-9": closing() || opening(),
"opacity-100 translate-y-0": !closing() && !opening(),
}}
>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</div>
</Show>
<div
classList={{
"relative z-10": true,
"transition-[margin] duration-[400ms] ease-out": true,
"-mt-9": dock() && !closing(),
"mt-0": !dock() || closing(),
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show>
</Show>
</div>
</div>
)
}
@@ -1,268 +1,156 @@
import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } from "solid-js" import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo" import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import FileTree from "@/components/file-tree" import FileTree from "@/components/file-tree"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
import { StickyAddButton } from "@/pages/session/review-tab"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useComments } from "@/context/comments"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useFile, type SelectedLineRange } from "@/context/file" import { useFile, type SelectedLineRange } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import type { Message, UserMessage } from "@opencode-ai/sdk/v2/client"
import { FileTabContent } from "@/pages/session/file-tabs"
import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers" type SessionSidePanelViewModel = {
import { StickyAddButton } from "@/pages/session/review-tab" messages: () => Message[]
import { setSessionHandoff } from "@/pages/session/handoff" visibleUserMessages: () => UserMessage[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
export function SessionSidePanel(props: { export function SessionSidePanel(props: {
open: boolean
reviewOpen: boolean
language: ReturnType<typeof useLanguage>
layout: ReturnType<typeof useLayout>
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
comments: ReturnType<typeof useComments>
hasReview: boolean
reviewCount: number
reviewTab: boolean
contextOpen: () => boolean
openedTabs: () => string[]
activeTab: () => string
activeFileTab: () => string | undefined
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
openTab: (value: string) => void
showAllFiles: () => void
reviewPanel: () => JSX.Element reviewPanel: () => JSX.Element
vm: SessionSidePanelViewModel
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
codeComponent: NonNullable<ValidComponent>
addCommentToContext: (input: {
file: string
selection: SelectedLineRange
comment: string
preview?: string
origin?: "review" | "file"
}) => void
activeDraggable: () => string | undefined
onDragStart: (event: unknown) => void
onDragEnd: () => void
onDragOver: (event: DragEvent) => void
fileTreeTab: () => "changes" | "all"
setFileTreeTabValue: (value: string) => void
diffsReady: boolean
diffFiles: string[]
kinds: Map<string, "add" | "del" | "mix">
activeDiff?: string activeDiff?: string
focusReviewDiff: (path: string) => void focusReviewDiff: (path: string) => void
}) { }) {
const params = useParams() const openedTabs = createMemo(() => props.openedTabs())
const layout = useLayout()
const sync = useSync()
const file = useFile()
const language = useLanguage()
const command = useCommand()
const dialog = useDialog()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
const reviewTab = createMemo(() => isDesktop() && !layout.fileTree.opened())
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasReview = createMemo(() => reviewCount() > 0)
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const diffFiles = createMemo(() => diffs().map((d) => d.file))
const kinds = createMemo(() => {
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
if (!a) return b
if (a === b) return a
return "mix" as const
}
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
const out = new Map<string, "add" | "del" | "mix">()
for (const diff of diffs()) {
const file = normalize(diff.file)
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
out.set(file, kind)
const parts = file.split("/")
for (const [idx] of parts.slice(0, -1).entries()) {
const dir = parts.slice(0, idx + 1).join("/")
if (!dir) continue
out.set(dir, merge(out.get(dir), kind))
}
}
return out
})
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
return file.tab(tab)
}
const openReviewPanel = () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
}
const openTab = createOpenSessionFileTab({
normalizeTab,
openTab: tabs().open,
pathFromTab: file.pathFromTab,
loadFile: file.load,
openReviewPanel,
setActive: tabs().setActive,
})
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
.all()
.filter((tab) => tab !== "context" && tab !== "review"),
)
const activeTab = createMemo(() => {
const active = tabs().active()
if (active === "context") return "context"
if (active === "review" && reviewTab()) return "review"
if (active && file.pathFromTab(active)) return normalizeTab(active)
const first = openedTabs()[0]
if (first) return first
if (contextOpen()) return "context"
if (reviewTab() && hasReview()) return "review"
return "empty"
})
const activeFileTab = createMemo(() => {
const active = activeTab()
if (!openedTabs().includes(active)) return
return active
})
const fileTreeTab = () => layout.fileTree.tab()
const setFileTreeTabValue = (value: string) => {
if (value !== "changes" && value !== "all") return
layout.fileTree.setTab(value)
}
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
layout.fileTree.setTab("all")
}
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
})
const handleDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const currentTabs = tabs().all()
const toIndex = getTabReorderIndex(currentTabs, draggable.id.toString(), droppable.id.toString())
if (toIndex === undefined) return
tabs().move(draggable.id.toString(), toIndex)
}
const handleDragEnd = () => {
setStore("activeDraggable", undefined)
}
createEffect(() => {
if (!file.ready()) return
setSessionHandoff(sessionKey(), {
files: tabs()
.all()
.reduce<Record<string, SelectedLineRange | null>>((acc, tab) => {
const path = file.pathFromTab(tab)
if (!path) return acc
const selected = file.selectedLines(path)
acc[path] =
selected && typeof selected === "object" && "start" in selected && "end" in selected
? (selected as SelectedLineRange)
: null
return acc
}, {}),
})
})
return ( return (
<Show when={open()}> <Show when={props.open}>
<aside <aside
id="review-panel" id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")} aria-label={props.language.t("session.panel.reviewAndFiles")}
class="relative min-w-0 h-full border-l border-border-weak-base flex" class="relative min-w-0 h-full border-l border-border-weak-base flex"
classList={{ classList={{
"flex-1": reviewOpen(), "flex-1": props.reviewOpen,
"shrink-0": !reviewOpen(), "shrink-0": !props.reviewOpen,
}} }}
style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }} style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }}
> >
<Show when={reviewOpen()}> <Show when={props.reviewOpen}>
<div class="flex-1 min-w-0 h-full"> <div class="flex-1 min-w-0 h-full">
<Show <Show
when={layout.fileTree.opened() && fileTreeTab() === "changes"} when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
fallback={ fallback={
<DragDropProvider <DragDropProvider
onDragStart={handleDragStart} onDragStart={props.onDragStart}
onDragEnd={handleDragEnd} onDragEnd={props.onDragEnd}
onDragOver={handleDragOver} onDragOver={props.onDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
<ConstrainDragYAxis /> <ConstrainDragYAxis />
<Tabs value={activeTab()} onChange={openTab}> <Tabs value={props.activeTab()} onChange={props.openTab}>
<div class="sticky top-0 shrink-0 flex"> <div class="sticky top-0 shrink-0 flex">
<Tabs.List <Tabs.List
ref={(el: HTMLDivElement) => { ref={(el: HTMLDivElement) => {
const stop = createFileTabListSync({ el, contextOpen }) const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
onCleanup(stop) onCleanup(stop)
}} }}
> >
<Show when={reviewTab()}> <Show when={props.reviewTab}>
<Tabs.Trigger value="review" classes={{ button: "!pl-6" }}> <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div>{language.t("session.tab.review")}</div> <div>{props.language.t("session.tab.review")}</div>
<Show when={hasReview()}> <Show when={props.hasReview}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base"> <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{reviewCount()} {props.reviewCount}
</div> </div>
</Show> </Show>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<Show when={contextOpen()}> <Show when={props.contextOpen()}>
<Tabs.Trigger <Tabs.Trigger
value="context" value="context"
closeButton={ closeButton={
<Tooltip value={language.t("common.closeTab")} placement="bottom"> <Tooltip value={props.language.t("common.closeTab")} placement="bottom">
<IconButton <IconButton
icon="close-small" icon="close-small"
variant="ghost" variant="ghost"
class="h-5 w-5" class="h-5 w-5"
onClick={() => tabs().close("context")} onClick={() => props.tabs().close("context")}
aria-label={language.t("common.closeTab")} aria-label={props.language.t("common.closeTab")}
/> />
</Tooltip> </Tooltip>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => tabs().close("context")} onMiddleClick={() => props.tabs().close("context")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" /> <SessionContextUsage variant="indicator" />
<div>{language.t("session.tab.context")}</div> <div>{props.language.t("session.tab.context")}</div>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<SortableProvider ids={openedTabs()}> <SortableProvider ids={openedTabs()}>
<For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For> <For each={openedTabs()}>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider> </SortableProvider>
<StickyAddButton> <StickyAddButton>
<TooltipKeybind <TooltipKeybind
title={language.t("command.file.open")} title={props.language.t("command.file.open")}
keybind={command.keybind("file.open")} keybind={props.command.keybind("file.open")}
class="flex items-center" class="flex items-center"
> >
<IconButton <IconButton
@@ -270,52 +158,72 @@ export function SessionSidePanel(props: {
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={() => onClick={() =>
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />) props.dialog.show(() => (
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
} }
aria-label={language.t("command.file.open")} aria-label={props.language.t("command.file.open")}
/> />
</TooltipKeybind> </TooltipKeybind>
</StickyAddButton> </StickyAddButton>
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={reviewTab()}> <Show when={props.reviewTab}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show> <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "empty"}> <Show when={props.activeTab() === "empty"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6"> <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" /> <Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56"> <div class="text-14-regular text-text-weak max-w-56">
{language.t("session.files.selectToOpen")} {props.language.t("session.files.selectToOpen")}
</div> </div>
</div> </div>
</div> </div>
</Show> </Show>
</Tabs.Content> </Tabs.Content>
<Show when={contextOpen()}> <Show when={props.contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}> <Show when={props.activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab /> <SessionContextTab
messages={props.vm.messages}
visibleUserMessages={props.vm.visibleUserMessages}
view={props.vm.view}
info={props.vm.info}
/>
</div> </div>
</Show> </Show>
</Tabs.Content> </Tabs.Content>
</Show> </Show>
<Show when={activeFileTab()} keyed> <Show when={props.activeFileTab()} keyed>
{(tab) => <FileTabContent tab={tab} />} {(tab) => (
<FileTabContent
tab={tab}
activeTab={props.activeTab}
tabs={props.tabs}
view={props.vm.view}
handoffFiles={props.handoffFiles}
file={props.file}
comments={props.comments}
language={props.language}
codeComponent={props.codeComponent}
addCommentToContext={props.addCommentToContext}
/>
)}
</Show> </Show>
</Tabs> </Tabs>
<DragOverlay> <DragOverlay>
<Show when={store.activeDraggable} keyed> <Show when={props.activeDraggable()}>
{(tab) => { {(tab) => {
const path = createMemo(() => file.pathFromTab(tab)) const path = createMemo(() => props.file.pathFromTab(tab()))
return ( return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent"> <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show> <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
@@ -332,44 +240,50 @@ export function SessionSidePanel(props: {
</div> </div>
</Show> </Show>
<Show when={layout.fileTree.opened()}> <Show when={props.layout.fileTree.opened()}>
<div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}> <div
id="file-tree-panel"
class="relative shrink-0 h-full"
style={{ width: `${props.layout.fileTree.width()}px` }}
>
<div <div
class="h-full flex flex-col overflow-hidden group/filetree" class="h-full flex flex-col overflow-hidden group/filetree"
classList={{ "border-l border-border-weak-base": reviewOpen() }} classList={{ "border-l border-border-weak-base": props.reviewOpen }}
> >
<Tabs <Tabs
variant="pill" variant="pill"
value={fileTreeTab()} value={props.fileTreeTab()}
onChange={setFileTreeTabValue} onChange={props.setFileTreeTabValue}
class="h-full" class="h-full"
data-scope="filetree" data-scope="filetree"
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
{reviewCount()}{" "} {props.reviewCount}{" "}
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} {props.language.t(
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}> <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
{language.t("session.files.all")} {props.language.t("session.files.all")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
<Switch> <Switch>
<Match when={hasReview()}> <Match when={props.hasReview}>
<Show <Show
when={diffsReady()} when={props.diffsReady}
fallback={ fallback={
<div class="px-2 py-2 text-12-regular text-text-weak"> <div class="px-2 py-2 text-12-regular text-text-weak">
{language.t("common.loading")} {props.language.t("common.loading")}
{language.t("common.loading.ellipsis")} {props.language.t("common.loading.ellipsis")}
</div> </div>
} }
> >
<FileTree <FileTree
path="" path=""
allowed={diffFiles()} allowed={props.diffFiles}
kinds={kinds()} kinds={props.kinds}
draggable={false} draggable={false}
active={props.activeDiff} active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)} onFileClick={(node) => props.focusReviewDiff(node.path)}
@@ -378,17 +292,17 @@ export function SessionSidePanel(props: {
</Match> </Match>
<Match when={true}> <Match when={true}>
<div class="mt-8 text-center text-12-regular text-text-weak"> <div class="mt-8 text-center text-12-regular text-text-weak">
{language.t("session.review.noChanges")} {props.language.t("session.review.noChanges")}
</div> </div>
</Match> </Match>
</Switch> </Switch>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0"> <Tabs.Content value="all" class="bg-background-base px-3 py-0">
<FileTree <FileTree
path="" path=""
modified={diffFiles()} modified={props.diffFiles}
kinds={kinds()} kinds={props.kinds}
onFileClick={(node) => openTab(file.tab(node.path))} onFileClick={(node) => props.openTab(props.file.tab(node.path))}
/> />
</Tabs.Content> </Tabs.Content>
</Tabs> </Tabs>
@@ -396,12 +310,12 @@ export function SessionSidePanel(props: {
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
edge="start" edge="start"
size={layout.fileTree.width()} size={props.layout.fileTree.width()}
min={200} min={200}
max={480} max={480}
collapseThreshold={160} collapseThreshold={160}
onResize={layout.fileTree.resize} onResize={props.layout.fileTree.resize}
onCollapse={layout.fileTree.close} onCollapse={props.layout.fileTree.close}
/> />
</div> </div>
</Show> </Show>
+68 -147
View File
@@ -1,161 +1,61 @@
import { For, Show, createEffect, createMemo, on } from "solid-js" import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis } from "@/utils/solid-dnd"
import { SortableTerminalTab } from "@/components/session" import { SortableTerminalTab } from "@/components/session"
import { Terminal } from "@/components/terminal" import { Terminal } from "@/components/terminal"
import { useCommand } from "@/context/command" import { useTerminal } from "@/context/terminal"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { terminalTabLabel } from "@/pages/session/terminal-label" import { terminalTabLabel } from "@/pages/session/terminal-label"
import { focusTerminalById } from "@/pages/session/helpers"
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
export function TerminalPanel() { export function TerminalPanel(props: {
const params = useParams() open: boolean
const layout = useLayout() height: number
const terminal = useTerminal() resize: (value: number) => void
const language = useLanguage() close: () => void
const command = useCommand() terminal: ReturnType<typeof useTerminal>
language: ReturnType<typeof useLanguage>
const isDesktop = createMediaQuery("(min-width: 768px)") command: ReturnType<typeof useCommand>
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) handoff: () => string[]
const view = createMemo(() => layout.view(sessionKey)) activeTerminalDraggable: () => string | undefined
handleTerminalDragStart: (event: unknown) => void
const opened = createMemo(() => view().terminal.opened()) handleTerminalDragOver: (event: DragEvent) => void
const open = createMemo(() => isDesktop() && opened()) handleTerminalDragEnd: () => void
const height = createMemo(() => layout.terminal.height()) onCloseTab: () => void
const close = () => view().terminal.close() }) {
const all = createMemo(() => props.terminal.all())
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
})
createEffect(() => {
if (!opened()) {
setStore("autoCreated", false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
terminal.new()
setStore("autoCreated", true)
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (opened()) view().terminal.toggle()
}
},
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !opened()) return
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
focusTerminalById(activeId)
},
),
)
createEffect(() => {
const dir = params.dir
if (!dir) return
if (!terminal.ready()) return
language.locale()
setTerminalHandoff(
dir,
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
titleNumber: pty.titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
}),
),
)
})
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
})
const all = createMemo(() => terminal.all())
const ids = createMemo(() => all().map((pty) => pty.id)) const ids = createMemo(() => all().map((pty) => pty.id))
const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty]))) const byId = createMemo(() => new Map(all().map((pty) => [pty.id, pty])))
const handleTerminalDragStart = (event: unknown) => {
const id = getDraggableId(event)
if (!id) return
setStore("activeDraggable", id)
}
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
terminal.move(draggable.id.toString(), toIndex)
}
}
const handleTerminalDragEnd = () => {
setStore("activeDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
focusTerminalById(activeId)
}, 0)
}
return ( return (
<Show when={open()}> <Show when={props.open}>
<div <div
id="terminal-panel" id="terminal-panel"
role="region" role="region"
aria-label={language.t("terminal.title")} aria-label={props.language.t("terminal.title")}
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base" class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${height()}px` }} style={{ height: `${props.height}px` }}
> >
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
size={height()} size={props.height}
min={100} min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50} collapseThreshold={50}
onResize={layout.terminal.resize} onResize={props.resize}
onCollapse={close} onCollapse={props.close}
/> />
<Show <Show
when={terminal.ready()} when={props.terminal.ready()}
fallback={ fallback={
<div class="flex flex-col h-full pointer-events-none"> <div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff()}> <For each={props.handoff()}>
{(title) => ( {(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title} {title}
@@ -164,18 +64,20 @@ export function TerminalPanel() {
</For> </For>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-text-weak pr-2"> <div class="text-text-weak pr-2">
{language.t("common.loading")} {props.language.t("common.loading")}
{language.t("common.loading.ellipsis")} {props.language.t("common.loading.ellipsis")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div> <div class="flex-1 flex items-center justify-center text-text-weak">
{props.language.t("terminal.loading")}
</div>
</div> </div>
} }
> >
<DragDropProvider <DragDropProvider
onDragStart={handleTerminalDragStart} onDragStart={props.handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd} onDragEnd={props.handleTerminalDragEnd}
onDragOver={handleTerminalDragOver} onDragOver={props.handleTerminalDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
@@ -183,26 +85,36 @@ export function TerminalPanel() {
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<Tabs <Tabs
variant="alt" variant="alt"
value={terminal.active()} value={props.terminal.active()}
onChange={(id) => terminal.open(id)} onChange={(id) => props.terminal.open(id)}
class="!h-auto !flex-none" class="!h-auto !flex-none"
> >
<Tabs.List class="h-10"> <Tabs.List class="h-10">
<SortableProvider ids={ids()}> <SortableProvider ids={ids()}>
<For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For> <For each={all()}>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
props.close()
props.onCloseTab()
}}
/>
)}
</For>
</SortableProvider> </SortableProvider>
<div class="h-full flex items-center justify-center"> <div class="h-full flex items-center justify-center">
<TooltipKeybind <TooltipKeybind
title={language.t("command.terminal.new")} title={props.language.t("command.terminal.new")}
keybind={command.keybind("terminal.new")} keybind={props.command.keybind("terminal.new")}
class="flex items-center" class="flex items-center"
> >
<IconButton <IconButton
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={terminal.new} onClick={props.terminal.new}
aria-label={language.t("command.terminal.new")} aria-label={props.language.t("command.terminal.new")}
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
@@ -215,11 +127,15 @@ export function TerminalPanel() {
id={`terminal-wrapper-${pty.id}`} id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0" class="absolute inset-0"
style={{ style={{
display: terminal.active() === pty.id ? "block" : "none", display: props.terminal.active() === pty.id ? "block" : "none",
}} }}
> >
<Show when={pty.id} keyed> <Show when={pty.id} keyed>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} /> <Terminal
pty={pty}
onCleanup={props.terminal.update}
onConnectError={() => props.terminal.clone(pty.id)}
/>
</Show> </Show>
</div> </div>
)} )}
@@ -227,20 +143,25 @@ export function TerminalPanel() {
</div> </div>
</div> </div>
<DragOverlay> <DragOverlay>
<Show when={store.activeDraggable}> <Show when={props.activeTerminalDraggable()}>
{(draggedId) => ( {(draggedId) => {
return (
<Show when={byId().get(draggedId())}> <Show when={byId().get(draggedId())}>
{(t) => ( {(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular"> <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{terminalTabLabel({ {terminalTabLabel({
title: t().title, title: t().title,
titleNumber: t().titleNumber, titleNumber: t().titleNumber,
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string, t: props.language.t as (
key: string,
vars?: Record<string, string | number | boolean>,
) => string,
})} })}
</div> </div>
)} )}
</Show> </Show>
)} )
}}
</Show> </Show>
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>
@@ -22,8 +22,29 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers" import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = { export type SessionCommandContext = {
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void focusInput: () => void
} }
@@ -34,98 +55,45 @@ const withCategory = (category: string) => {
}) })
} }
export const useSessionCommands = (actions: SessionCommandContext) => { export const useSessionCommands = (input: SessionCommandContext) => {
const command = useCommand() const sessionCommand = withCategory(input.language.t("command.category.session"))
const dialog = useDialog() const fileCommand = withCategory(input.language.t("command.category.file"))
const file = useFile() const contextCommand = withCategory(input.language.t("command.category.context"))
const language = useLanguage() const viewCommand = withCategory(input.language.t("command.category.view"))
const local = useLocal() const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const permission = usePermission() const modelCommand = withCategory(input.language.t("command.category.model"))
const prompt = usePrompt() const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const sdk = useSDK() const agentCommand = withCategory(input.language.t("command.category.agent"))
const sync = useSync() const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const showAllFiles = () => {
if (layout.fileTree.tab() !== "changes") return
layout.fileTree.setTab("all")
}
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const navigateMessageByOffset = actions.navigateMessageByOffset
const setActiveMessage = actions.setActiveMessage
const focusInput = actions.focusInput
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [ const sessionCommands = createMemo(() => [
sessionCommand({ sessionCommand({
id: "session.new", id: "session.new",
title: language.t("command.session.new"), title: input.language.t("command.session.new"),
keybind: "mod+shift+s", keybind: "mod+shift+s",
slash: "new", slash: "new",
onSelect: () => navigate(`/${params.dir}/session`), onSelect: () => input.navigate(`/${input.params.dir}/session`),
}), }),
]) ])
const fileCommands = createMemo(() => [ const fileCommands = createMemo(() => [
fileCommand({ fileCommand({
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: input.language.t("command.file.open"),
description: language.t("palette.search.placeholder"), description: input.language.t("palette.search.placeholder"),
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}), }),
fileCommand({ fileCommand({
id: "tab.close", id: "tab.close",
title: language.t("command.tab.close"), title: input.language.t("command.tab.close"),
keybind: "mod+w", keybind: "mod+w",
disabled: !tabs().active(), disabled: !input.tabs().active(),
onSelect: () => { onSelect: () => {
const active = tabs().active() const active = input.tabs().active()
if (!active) return if (!active) return
tabs().close(active) input.tabs().close(active)
}, },
}), }),
]) ])
@@ -133,30 +101,30 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const contextCommands = createMemo(() => [ const contextCommands = createMemo(() => [
contextCommand({ contextCommand({
id: "context.addSelection", id: "context.addSelection",
title: language.t("command.context.addSelection"), title: input.language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"), description: input.language.t("command.context.addSelection.description"),
keybind: "mod+shift+l", keybind: "mod+shift+l",
disabled: !canAddSelectionContext({ disabled: !canAddSelectionContext({
active: tabs().active(), active: input.tabs().active(),
pathFromTab: file.pathFromTab, pathFromTab: input.file.pathFromTab,
selectedLines: file.selectedLines, selectedLines: input.file.selectedLines,
}), }),
onSelect: () => { onSelect: () => {
const active = tabs().active() const active = input.tabs().active()
if (!active) return if (!active) return
const path = file.pathFromTab(active) const path = input.file.pathFromTab(active)
if (!path) return if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) { if (!range) {
showToast({ showToast({
title: language.t("toast.context.noLineSelection.title"), title: input.language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"), description: input.language.t("toast.context.noLineSelection.description"),
}) })
return return
} }
addSelectionToContext(path, selectionFromLines(range)) input.addSelectionToContext(path, selectionFromLines(range))
}, },
}), }),
]) ])
@@ -164,37 +132,37 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const viewCommands = createMemo(() => [ const viewCommands = createMemo(() => [
viewCommand({ viewCommand({
id: "terminal.toggle", id: "terminal.toggle",
title: language.t("command.terminal.toggle"), title: input.language.t("command.terminal.toggle"),
keybind: "ctrl+`", keybind: "ctrl+`",
slash: "terminal", slash: "terminal",
onSelect: () => view().terminal.toggle(), onSelect: () => input.view().terminal.toggle(),
}), }),
viewCommand({ viewCommand({
id: "review.toggle", id: "review.toggle",
title: language.t("command.review.toggle"), title: input.language.t("command.review.toggle"),
keybind: "mod+shift+r", keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(), onSelect: () => input.view().reviewPanel.toggle(),
}), }),
viewCommand({ viewCommand({
id: "fileTree.toggle", id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"), title: input.language.t("command.fileTree.toggle"),
keybind: "mod+\\", keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(), onSelect: () => input.layout.fileTree.toggle(),
}), }),
viewCommand({ viewCommand({
id: "input.focus", id: "input.focus",
title: language.t("command.input.focus"), title: input.language.t("command.input.focus"),
keybind: "ctrl+l", keybind: "ctrl+l",
onSelect: () => focusInput(), onSelect: () => input.focusInput(),
}), }),
terminalCommand({ terminalCommand({
id: "terminal.new", id: "terminal.new",
title: language.t("command.terminal.new"), title: input.language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"), description: input.language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t", keybind: "ctrl+alt+t",
onSelect: () => { onSelect: () => {
if (terminal.all().length > 0) terminal.new() if (input.terminal.all().length > 0) input.terminal.new()
view().terminal.open() input.view().terminal.open()
}, },
}), }),
]) ])
@@ -202,61 +170,61 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const messageCommands = createMemo(() => [ const messageCommands = createMemo(() => [
sessionCommand({ sessionCommand({
id: "message.previous", id: "message.previous",
title: language.t("command.message.previous"), title: input.language.t("command.message.previous"),
description: language.t("command.message.previous.description"), description: input.language.t("command.message.previous.description"),
keybind: "mod+arrowup", keybind: "mod+arrowup",
disabled: !params.id, disabled: !input.params.id,
onSelect: () => navigateMessageByOffset(-1), onSelect: () => input.navigateMessageByOffset(-1),
}), }),
sessionCommand({ sessionCommand({
id: "message.next", id: "message.next",
title: language.t("command.message.next"), title: input.language.t("command.message.next"),
description: language.t("command.message.next.description"), description: input.language.t("command.message.next.description"),
keybind: "mod+arrowdown", keybind: "mod+arrowdown",
disabled: !params.id, disabled: !input.params.id,
onSelect: () => navigateMessageByOffset(1), onSelect: () => input.navigateMessageByOffset(1),
}), }),
]) ])
const agentCommands = createMemo(() => [ const agentCommands = createMemo(() => [
modelCommand({ modelCommand({
id: "model.choose", id: "model.choose",
title: language.t("command.model.choose"), title: input.language.t("command.model.choose"),
description: language.t("command.model.choose.description"), description: input.language.t("command.model.choose.description"),
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />), onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}), }),
mcpCommand({ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
title: language.t("command.mcp.toggle"), title: input.language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"), description: input.language.t("command.mcp.toggle.description"),
keybind: "mod+;", keybind: "mod+;",
slash: "mcp", slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />), onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}), }),
agentCommand({ agentCommand({
id: "agent.cycle", id: "agent.cycle",
title: language.t("command.agent.cycle"), title: input.language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"), description: input.language.t("command.agent.cycle.description"),
keybind: "mod+.", keybind: "mod+.",
slash: "agent", slash: "agent",
onSelect: () => local.agent.move(1), onSelect: () => input.local.agent.move(1),
}), }),
agentCommand({ agentCommand({
id: "agent.cycle.reverse", id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"), title: input.language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"), description: input.language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), onSelect: () => input.local.agent.move(-1),
}), }),
modelCommand({ modelCommand({
id: "model.variant.cycle", id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"), title: input.language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"), description: input.language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d", keybind: "shift+mod+d",
onSelect: () => { onSelect: () => {
local.model.variant.cycle() input.local.model.variant.cycle()
}, },
}), }),
]) ])
@@ -265,22 +233,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
permissionsCommand({ permissionsCommand({
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: title:
params.id && permission.isAutoAccepting(params.id, sdk.directory) input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? language.t("command.permissions.autoaccept.disable") ? input.language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"), : input.language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(), disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => { onSelect: () => {
const sessionID = params.id const sessionID = input.params.id
if (!sessionID) return if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory) input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
showToast({ showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory) title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? language.t("toast.permissions.autoaccept.on.title") ? input.language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"), : input.language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory) description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? language.t("toast.permissions.autoaccept.on.description") ? input.language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"), : input.language.t("toast.permissions.autoaccept.off.description"),
}) })
}, },
}), }),
@@ -289,71 +257,71 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [ const sessionActionCommands = createMemo(() => [
sessionCommand({ sessionCommand({
id: "session.undo", id: "session.undo",
title: language.t("command.session.undo"), title: input.language.t("command.session.undo"),
description: language.t("command.session.undo.description"), description: input.language.t("command.session.undo.description"),
slash: "undo", slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
const sessionID = params.id const sessionID = input.params.id
if (!sessionID) return if (!sessionID) return
if (status()?.type !== "idle") { if (input.status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {}) await input.sdk.client.session.abort({ sessionID }).catch(() => {})
} }
const revert = info()?.revert?.messageID const revert = input.info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert) const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
if (!message) return if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id }) await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id] const parts = input.sync.data.part[message.id]
if (parts) { if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory }) const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
prompt.set(restored) input.prompt.set(restored)
} }
const priorMessage = findLast(userMessages(), (x) => x.id < message.id) const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage) input.setActiveMessage(priorMessage)
}, },
}), }),
sessionCommand({ sessionCommand({
id: "session.redo", id: "session.redo",
title: language.t("command.session.redo"), title: input.language.t("command.session.redo"),
description: language.t("command.session.redo.description"), description: input.language.t("command.session.redo.description"),
slash: "redo", slash: "redo",
disabled: !params.id || !info()?.revert?.messageID, disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => { onSelect: async () => {
const sessionID = params.id const sessionID = input.params.id
if (!sessionID) return if (!sessionID) return
const revertMessageID = info()?.revert?.messageID const revertMessageID = input.info()?.revert?.messageID
if (!revertMessageID) return if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID) const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) { if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID }) await input.sdk.client.session.unrevert({ sessionID })
prompt.reset() input.prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg) input.setActiveMessage(lastMsg)
return return
} }
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg) input.setActiveMessage(priorMsg)
}, },
}), }),
sessionCommand({ sessionCommand({
id: "session.compact", id: "session.compact",
title: language.t("command.session.compact"), title: input.language.t("command.session.compact"),
description: language.t("command.session.compact.description"), description: input.language.t("command.session.compact.description"),
slash: "compact", slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => { onSelect: async () => {
const sessionID = params.id const sessionID = input.params.id
if (!sessionID) return if (!sessionID) return
const model = local.model.current() const model = input.local.model.current()
if (!model) { if (!model) {
showToast({ showToast({
title: language.t("toast.model.none.title"), title: input.language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"), description: input.language.t("toast.model.none.description"),
}) })
return return
} }
await sdk.client.session.summarize({ await input.sdk.client.session.summarize({
sessionID, sessionID,
modelID: model.id, modelID: model.id,
providerID: model.provider.id, providerID: model.provider.id,
@@ -362,27 +330,29 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}), }),
sessionCommand({ sessionCommand({
id: "session.fork", id: "session.fork",
title: language.t("command.session.fork"), title: input.language.t("command.session.fork"),
description: language.t("command.session.fork.description"), description: input.language.t("command.session.fork.description"),
slash: "fork", slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0, disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />), onSelect: () => input.dialog.show(() => <DialogFork />),
}), }),
]) ])
const shareCommands = createMemo(() => { const shareCommands = createMemo(() => {
if (sync.data.config.share === "disabled") return [] if (input.sync.data.config.share === "disabled") return []
return [ return [
sessionCommand({ sessionCommand({
id: "session.share", id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), title: input.info()?.share?.url
description: info()?.share?.url ? input.language.t("session.share.copy.copyLink")
? language.t("toast.session.share.success.description") : input.language.t("command.session.share"),
: language.t("command.session.share.description"), description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
slash: "share", slash: "share",
disabled: !params.id, disabled: !input.params.id,
onSelect: async () => { onSelect: async () => {
if (!params.id) return if (!input.params.id) return
const write = (value: string) => { const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body const body = typeof document === "undefined" ? undefined : document.body
@@ -412,7 +382,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const ok = await write(url) const ok = await write(url)
if (!ok) { if (!ok) {
showToast({ showToast({
title: language.t("toast.session.share.copyFailed.title"), title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error", variant: "error",
}) })
return return
@@ -420,27 +390,27 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
showToast({ showToast({
title: existing title: existing
? language.t("session.share.copy.copied") ? input.language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"), : input.language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"), description: input.language.t("toast.session.share.success.description"),
variant: "success", variant: "success",
}) })
} }
const existing = info()?.share?.url const existing = input.info()?.share?.url
if (existing) { if (existing) {
await copy(existing, true) await copy(existing, true)
return return
} }
const url = await sdk.client.session const url = await input.sdk.client.session
.share({ sessionID: params.id }) .share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url) .then((res) => res.data?.share?.url)
.catch(() => undefined) .catch(() => undefined)
if (!url) { if (!url) {
showToast({ showToast({
title: language.t("toast.session.share.failed.title"), title: input.language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"), description: input.language.t("toast.session.share.failed.description"),
variant: "error", variant: "error",
}) })
return return
@@ -451,25 +421,25 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}), }),
sessionCommand({ sessionCommand({
id: "session.unshare", id: "session.unshare",
title: language.t("command.session.unshare"), title: input.language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"), description: input.language.t("command.session.unshare.description"),
slash: "unshare", slash: "unshare",
disabled: !params.id || !info()?.share?.url, disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => { onSelect: async () => {
if (!params.id) return if (!input.params.id) return
await sdk.client.session await input.sdk.client.session
.unshare({ sessionID: params.id }) .unshare({ sessionID: input.params.id })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: input.language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"), description: input.language.t("toast.session.unshare.success.description"),
variant: "success", variant: "success",
}), }),
) )
.catch(() => .catch(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.failed.title"), title: input.language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"), description: input.language.t("toast.session.unshare.failed.description"),
variant: "error", variant: "error",
}), }),
) )
@@ -478,7 +448,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
] ]
}) })
command.register("session", () => input.command.register("session", () =>
[ [
sessionCommands(), sessionCommands(),
fileCommands(), fileCommands(),
-2
View File
@@ -1,8 +1,6 @@
/* This file is auto-generated by SST. Do not edit. */ /* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* biome-ignore-all lint: auto-generated */
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
+135
View File
@@ -0,0 +1,135 @@
import { uuid } from "@/utils/uuid"
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uuid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uuid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}
+6 -15
View File
@@ -1,11 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import { checkServerHealth } from "./server-health" import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
url: "http://localhost:4096",
}
function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { function abortFromInput(input: RequestInfo | URL, init?: RequestInit) {
if (init?.signal) return init.signal if (init?.signal) return init.signal
if (input instanceof Request) return input.signal if (input instanceof Request) return input.signal
@@ -20,7 +15,7 @@ describe("checkServerHealth", () => {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch) const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" }) expect(result).toEqual({ healthy: true, version: "1.2.3" })
}) })
@@ -30,7 +25,7 @@ describe("checkServerHealth", () => {
throw new Error("network") throw new Error("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch) const result = await checkServerHealth("http://localhost:4096", fetch)
expect(result).toEqual({ healthy: false }) expect(result).toEqual({ healthy: false })
}) })
@@ -56,9 +51,7 @@ describe("checkServerHealth", () => {
) )
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, { const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => {
timeoutMs: 10,
}).finally(() => {
if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout)
if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout")
}) })
@@ -78,9 +71,7 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const abort = new AbortController() const abort = new AbortController()
await checkServerHealth(server, fetch, { await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal })
signal: abort.signal,
})
expect(signal).toBe(abort.signal) expect(signal).toBe(abort.signal)
}) })
@@ -96,7 +87,7 @@ describe("checkServerHealth", () => {
}) })
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, { const result = await checkServerHealth("http://localhost:4096", fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
@@ -112,7 +103,7 @@ describe("checkServerHealth", () => {
throw new TypeError("network") throw new TypeError("network")
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth(server, fetch, { const result = await checkServerHealth("http://localhost:4096", fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
+5 -9
View File
@@ -1,5 +1,4 @@
import type { ServerConnection } from "@/context/server" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSdkForServer } from "./server"
export type ServerHealth = { healthy: boolean; version?: string } export type ServerHealth = { healthy: boolean; version?: string }
@@ -18,10 +17,7 @@ function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
if (timeout) { if (timeout) {
try { try {
return { return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined }
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
} catch {} } catch {}
} }
const controller = new AbortController() const controller = new AbortController()
@@ -56,7 +52,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
} }
export async function checkServerHealth( export async function checkServerHealth(
server: ServerConnection.HttpBase, url: string,
fetch: typeof globalThis.fetch, fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions, opts?: CheckServerHealthOptions,
): Promise<ServerHealth> { ): Promise<ServerHealth> {
@@ -71,8 +67,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false })) .catch(() => ({ healthy: false }))
} }
const attempt = (count: number): Promise<ServerHealth> => const attempt = (count: number): Promise<ServerHealth> =>
createSdkForServer({ createOpencodeClient({
server, baseUrl: url,
fetch, fetch,
signal, signal,
}) })
-22
View File
@@ -1,22 +0,0 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import type { ServerConnection } from "@/context/server"
export function createSdkForServer({
server,
...config
}: Omit<NonNullable<Parameters<typeof createOpencodeClient>[0]>, "baseUrl"> & {
server: ServerConnection.HttpBase
}) {
const auth = (() => {
if (!server.password) return
return {
Authorization: `Basic ${btoa(`${server.username ?? "opencode"}:${server.password}`)}`,
}
})()
return createOpencodeClient({
...config,
headers: { ...config.headers, ...auth },
baseUrl: server.url,
})
}
-1
View File
@@ -2,7 +2,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* deno-fmt-ignore-file */ /* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" /> /// <reference path="../../sst-env.d.ts" />
+1 -2
View File
@@ -22,6 +22,5 @@
} }
}, },
"include": ["src", "package.json"], "include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"], "exclude": ["dist", "ts-dist"]
"references": [{ "path": "../sdk/js" }]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.8", "version": "1.2.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
-1
View File
@@ -2,7 +2,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* deno-fmt-ignore-file */ /* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" /> /// <reference path="../../../sst-env.d.ts" />
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.8", "version": "1.2.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
-1
View File
@@ -2,7 +2,6 @@
/* tslint:disable */ /* tslint:disable */
/* eslint-disable */ /* eslint-disable */
/* deno-fmt-ignore-file */ /* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
import "sst" import "sst"
declare module "sst" { declare module "sst" {

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