Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory 2026-02-18 19:10:26 -05:00
commit b70629af27
320 changed files with 15372 additions and 10015 deletions

View file

@ -3,12 +3,13 @@ description: "Setup Bun with caching and install dependencies"
runs: runs:
using: "composite" using: "composite"
steps: steps:
- name: Mount Bun Cache - name: Cache Bun dependencies
if: ${{ runner.os == 'Linux' }} uses: actions/cache@v4
uses: useblacksmith/stickydisk@v1
with: with:
key: ${{ github.repository }}-bun-cache-${{ runner.os }} path: ~/.bun/install/cache
path: ~/.bun key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v2 uses: oven-sh/setup-bun@v2

View file

@ -1,46 +0,0 @@
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
.github/workflows/nix-eval.yml vendored Normal file
View file

@ -0,0 +1,95 @@
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 ==="

View file

@ -6,7 +6,7 @@ permissions:
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
branches: [dev] branches: [dev, beta]
paths: paths:
- "bun.lock" - "bun.lock"
- "package.json" - "package.json"

View file

@ -171,13 +171,23 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch - name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu') if: contains(matrix.settings.host, 'ubuntu')
run: | with:
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force tool: tauri-cli
echo "Installed tauri-cli version:" git: https://github.com/tauri-apps/tauri
cargo tauri --version # branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- name: Build and upload artifacts - name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a

View file

@ -1,7 +1,7 @@
--- ---
mode: primary mode: primary
hidden: true hidden: true
model: opencode/claude-haiku-4-5 model: opencode/minimax-m2.5
color: "#44BA81" color: "#44BA81"
tools: tools:
"*": false "*": false
@ -12,6 +12,8 @@ You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues. Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules.
## Labels ## Labels
### windows ### windows
@ -43,12 +45,34 @@ Desktop app issues:
**Only** add if the issue explicitly mentions nix. **Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen #### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black". **Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs #### docs
Add if the issue requests better documentation or docs updates. Add if the issue requests better documentation or docs updates.
@ -66,13 +90,51 @@ TUI issues potentially caused by our underlying TUI library:
When assigning to people here are the following rules: When assigning to people here are the following rules:
adamdotdev: Desktop / Web:
ONLY assign adam if the issue will have the "desktop" label. Use for desktop-labeled issues only.
fwang: - adamdotdevin
ONLY assign fwang if the issue will have the "zen" label. - iamdavidhill
- Brendonovich
- nexxeln
jayair: Zen:
ONLY assign jayair if the issue will have the "docs" label. ONLY assign if the issue will have the "zen" label.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node. - fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)

View file

@ -1,8 +1,22 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
// import { Octokit } from "@octokit/rest"
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt" import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
tui: ["thdxr", "kommander", "rekram1-node"],
core: ["thdxr", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"],
} as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]!
}
function getIssueNumber(): number { function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10) const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set") if (!issue) throw new Error("ISSUE_NUMBER env var not set")
@ -29,60 +43,69 @@ export default tool({
description: DESCRIPTION, description: DESCRIPTION,
args: { args: {
assignee: tool.schema assignee: tool.schema
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"]) .enum(ASSIGNEES as [string, ...string[]])
.describe("The username of the assignee") .describe("The username of the assignee")
.default("rekram1-node"), .default("rekram1-node"),
labels: tool.schema labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"])) .array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue") .describe("The labels(s) to add to the issue")
.default([]), .default([]),
}, },
async execute(args) { async execute(args) {
const issue = getIssueNumber() const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "anomalyco" const owner = "anomalyco"
const repo = "opencode" const repo = "opencode"
const results: string[] = [] const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) { if (labels.includes("nix") && !nix) {
throw new Error("Only desktop issues should be assigned to adamdotdevin") labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
} }
if (args.assignee === "fwang" && !args.labels.includes("zen")) { const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
throw new Error("Only zen issues should be assigned to fwang")
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
} }
if (args.assignee === "kommander" && !args.labels.includes("opentui")) { if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander") throw new Error("Only opentui issues should be assigned to kommander")
} }
// await octokit.rest.issues.addAssignees({
// owner,
// repo,
// issue_number: issue,
// assignees: [args.assignee],
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST", method: "POST",
body: JSON.stringify({ assignees: [args.assignee] }), body: JSON.stringify({ assignees: [assignee] }),
}) })
results.push(`Assigned @${args.assignee} to issue #${issue}`) results.push(`Assigned @${assignee} to issue #${issue}`)
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
if (labels.length > 0) { if (labels.length > 0) {
// await octokit.rest.issues.addLabels({
// owner,
// repo,
// issue_number: issue,
// labels,
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, { await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST", method: "POST",
body: JSON.stringify({ labels }), body: JSON.stringify({ labels }),
}) })
results.push(`Added labels: ${args.labels.join(", ")}`) results.push(`Added labels: ${labels.join(", ")}`)
} }
return results.join("\n") return results.join("\n")

View file

@ -1,88 +1,6 @@
Use this tool to assign and/or label a GitHub issue. Use this tool to assign and/or label a GitHub issue.
You can assign the following users: Choose labels and assignee using the current triage policy and ownership rules.
- thdxr Pick the most fitting labels for the issue and assign one owner.
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS

9
.zed/settings.json Normal file
View file

@ -0,0 +1,9 @@
{
"format_on_save": "on",
"formatter": {
"external": {
"command": "bunx",
"arguments": ["prettier", "--stdin-filepath", "{buffer_path}"]
}
}
}

View file

@ -24,6 +24,11 @@ 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+

View file

@ -1,5 +1,11 @@
# 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

117
bun.lock
View file

@ -14,10 +14,11 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"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.17.23", "sst": "3.18.10",
"turbo": "2.5.6", "turbo": "2.5.6",
}, },
}, },
@ -288,7 +289,7 @@
"@ai-sdk/vercel": "1.0.33", "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51", "@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.1", "@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3", "@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
@ -324,6 +325,7 @@
"hono-openapi": "catalog:", "hono-openapi": "catalog:",
"ignore": "7.0.5", "ignore": "7.0.5",
"jsonc-parser": "3.3.1", "jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
@ -356,6 +358,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/babel__core": "7.20.5", "@types/babel__core": "7.20.5",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/yargs": "17.0.33", "@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
@ -989,7 +992,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-I8+EGdUeKmGJSjAdFobHtqpxM9Fm00w0j7NJbtln/D/XQ1SKEGoZIuqJko4v0pV2mkhGUIs7qezljH/2kbXovA=="], "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="], "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
@ -1609,7 +1612,7 @@
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="], "@smithy/config-resolver": ["@smithy/config-resolver@4.4.6", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ=="],
"@smithy/core": ["@smithy/core@3.23.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-Yq4UPVoQICM9zHnByLmG8632t2M0+yap4T7ANVw482J0W7HW0pOuxwVmeOwzJqX2Q89fkXz0Vybz55Wj2Xzrsg=="], "@smithy/core": ["@smithy/core@3.23.2", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.9", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-stream": "^4.5.12", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
@ -1639,9 +1642,9 @@
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="], "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.14", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FUFNE5KVeaY6U/GL0nzAAHkaCHzXLZcY1EhtQnsAqhD8Du13oPKtMB9/0WK4/LK6a/T5OZ24wPoSShff5iI6Ag=="], "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.16", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-serde": "^4.2.9", "@smithy/node-config-provider": "^4.3.8", "@smithy/shared-ini-file-loader": "^4.4.3", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-middleware": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.31", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-RXBzLpMkIrxBPe4C8OmEOHvS8aH9RUuCOH++Acb5jZDEblxDjyg6un72X9IcbrGTJoiUwmI7hLypNfuDACypbg=="], "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.33", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/protocol-http": "^5.3.8", "@smithy/service-error-classification": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="], "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.9", "", { "dependencies": { "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ=="],
@ -1665,7 +1668,7 @@
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="], "@smithy/signature-v4": ["@smithy/signature-v4@5.3.8", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.8", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.11.3", "", { "dependencies": { "@smithy/core": "^3.23.0", "@smithy/middleware-endpoint": "^4.4.14", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-Q7kY5sDau8OoE6Y9zJoRGgje8P4/UY0WzH8R2ok0PDh+iJ+ZnEKowhjEqYafVcubkbYxQVaqwm3iufktzhprGg=="], "@smithy/smithy-client": ["@smithy/smithy-client@4.11.5", "", { "dependencies": { "@smithy/core": "^3.23.2", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-stack": "^4.2.8", "@smithy/protocol-http": "^5.3.8", "@smithy/types": "^4.12.0", "@smithy/util-stream": "^4.5.12", "tslib": "^2.6.2" } }, "sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ=="],
"@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="], "@smithy/types": ["@smithy/types@4.12.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw=="],
@ -1681,9 +1684,9 @@
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="], "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.30", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-cMni0uVU27zxOiU8TuC8pQLC1pYeZ/xEMxvchSK/ILwleRd1ugobOcIRr5vXtcRqKd4aBLWlpeBoDPJJ91LQng=="], "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.32", "", { "dependencies": { "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.33", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.3", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-LEb2aq5F4oZUSzWBG7S53d4UytZSkOEJPXcBq/xbG2/TmK9EW5naUZ8lKu1BEyWMzdHIzEVN16M3k8oxDq+DJA=="], "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.35", "", { "dependencies": { "@smithy/config-resolver": "^4.4.6", "@smithy/credential-provider-imds": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="], "@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw=="],
@ -1917,6 +1920,8 @@
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
"@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="], "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
@ -1983,7 +1988,7 @@
"@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="], "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251207.1", "", { "os": "win32", "cpu": "x64" }, "sha512-5l51HlXjX7lXwo65DEl1IaCFLjmkMtL6K3NrSEamPNeNTtTQwZRa3pQ9V65dCglnnCQ0M3+VF1RqzC7FU0iDKg=="],
"@typescript/vfs": ["@typescript/vfs@1.6.2", "", { "dependencies": { "debug": "^4.1.1" }, "peerDependencies": { "typescript": "*" } }, "sha512-hoBwJwcbKHmvd2QVebiytN1aELvpk9B74B4L1mFm/XT1Q/VOYAWl2vQ9AWRFtQq8zmz6enTpfTV8WRc4ATjW/g=="], "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="],
"@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="], "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.3", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-91fp6CAAJSRtH5ja95T1FHSKa8aPW9/Zw6cta81jlZTUw/+Vq8jM/AfF/14h2b71wwR84JUTW/3Y8QPhDAawFA=="],
@ -2049,7 +2054,7 @@
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="], "ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
@ -2131,11 +2136,11 @@
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"b4a": ["b4a@1.7.4", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-u20zJLDaSWpxaZ+zaAkEIB2dZZ1o+DF4T/MRbmsvGp9nletHOyiai19OzX1fF8xUBYsO1bPXxODvcd0978pnug=="], "b4a": ["b4a@1.7.5", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="], "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.5", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-8TFKemVLDYezqqv4mWz+PhRrkryTzivTGu0twyLrOkVZ0P63COx2Y04eVsUjFlwSOXui1z3P3Pn209dokWnirg=="],
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="], "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
@ -2211,13 +2216,13 @@
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eDgLN9teKTfmvrCqgwwmWNsNszxYs7IZdCqk0S1DCarvMhr4wcajoSBlA/nQA0/owwLduPTS8xxCnQp4/N/gDg=="], "bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qM7W5IaFpWYGPDcNiQ8DOng3noQ97gxpH2MFH1mGsdKwI0T4oy++egSh5Z7s6AQx8WKgc9GzAsTUM4KZkFdacw=="],
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-X+PjwJUWenUmdQBP8EtdItMyieQ6Nlpn+BH518oaouDiSnWj5+b0Y7DNDZJq7Ezom4EaxmqL/uGYZK3aCQ7CXg=="], "bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-oVoIsme27pcXB68YxnQSAgdNGCa4A3PGWYIBUewOh9VnJaoik4JenGb5Yy+svGE+ETFhQXV9nhHqgMPsDRrO6A=="],
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-zMLs2YIGB+/jxrYFXaFhVKX/GBt05UTF45lc9srcHc9JXGjEj+12CIo1CHLTAWatXMTqt0Jsu6ukWEoWVT/ayA=="], "bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+SYt09k+xDEl/GfcU7L1zdNgm7IlvAFKV5Xl/auBwuprKG5UwXNhjRlRAWfhTMCUZWN+NDf8E+ZQx0cQi9K2/g=="],
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="], "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-zvnUl4EAsQbKsmZVu+lEJcH8axQ7MiCfqg2OmnHd6uw1THABmHaX0GbpKiHshdgadNN2Nf+4zDyTJB5YMcAdrA=="],
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
@ -2237,7 +2242,7 @@
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@ -2471,7 +2476,7 @@
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="], "error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
@ -2707,7 +2712,7 @@
"h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="], "h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="],
"happy-dom": ["happy-dom@20.6.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^6.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-+0vhESXXhFwkdjZnJ5DlmJIfUYGgIEEjzIjB+aKJbFuqlvvKyOi+XkI1fYbgYR9QCxG5T08koxsQ6HrQfa5gCQ=="], "happy-dom": ["happy-dom@20.6.2", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-Xk/Y0cuq9ngN/my8uvK4gKoyDl6sBKkIl8A/hJ0IabZVH7E5SJLHNE7uKRPVmSrQbhJaLIHTEcvTct4GgNtsRA=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
@ -2919,7 +2924,7 @@
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="], "is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="], "is64bit": ["is64bit@2.0.0", "", { "dependencies": { "system-architecture": "^0.1.0" } }, "sha512-jv+8jaWCl0g2lSBkNSVXdzfBA0npK1HGC2KtWM9FumFRoGS94g3NbCCLVnCYHLjp4GrW2KZeeSTMo5ddtznmGw=="],
@ -3497,7 +3502,7 @@
"punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="], "punycode": ["punycode@1.3.2", "", {}, "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw=="],
"qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
@ -3759,23 +3764,23 @@
"srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="], "srvx": ["srvx@0.9.8", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-RZaxTKJEE/14HYn8COLuUOJAt0U55N9l1Xf6jj+T0GoA01EUH1Xz5JtSUOI+EHn+AEgPCVn7gk6jHJffrr06fQ=="],
"sst": ["sst@3.17.23", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.23", "sst-darwin-x64": "3.17.23", "sst-linux-arm64": "3.17.23", "sst-linux-x64": "3.17.23", "sst-linux-x86": "3.17.23", "sst-win32-arm64": "3.17.23", "sst-win32-x64": "3.17.23", "sst-win32-x86": "3.17.23" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-TwKgUgDnZdc1Swe+bvCNeyO4dQnYz5cTodMpYj3jlXZdK9/KNz0PVxT1f0u5E76i1pmilXrUBL/f7iiMPw4RDg=="], "sst": ["sst@3.18.10", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.18.10", "sst-darwin-x64": "3.18.10", "sst-linux-arm64": "3.18.10", "sst-linux-x64": "3.18.10", "sst-linux-x86": "3.18.10", "sst-win32-arm64": "3.18.10", "sst-win32-x64": "3.18.10", "sst-win32-x86": "3.18.10" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-SY+ldeJ9K5E9q+DhjXA3e2W3BEOzBwkE3IyLSD71uA3/5nRhUAST31iOWEpW36LbIvSQ9uOVDFcebztoLJ8s7w=="],
"sst-darwin-arm64": ["sst-darwin-arm64@3.17.23", "", { "os": "darwin", "cpu": "arm64" }, "sha512-R6kvmF+rUideOoU7KBs2SdvrIupoE+b+Dor/eq9Uo4Dojj7KvYDZI/EDm8sSCbbcx/opiWeyNqKtlnLEdCxE6g=="], "sst-darwin-arm64": ["sst-darwin-arm64@3.18.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3MwIpMZhhdZKDqLp9ZQNlwkWix5+q+N0PWstuTomYwgZOxCCe6u9IIsoIszSk+GAJJN/jvGZyLiXKeV4iiQvw=="],
"sst-darwin-x64": ["sst-darwin-x64@3.17.23", "", { "os": "darwin", "cpu": "x64" }, "sha512-WW4P1S35iYCifQXxD+sE3wuzcN+LHLpuKMaNoaBqEcWGZnH3IPaDJ7rpLF0arkDAo/z3jZmWWzOCkr0JuqJ8vQ=="], "sst-darwin-x64": ["sst-darwin-x64@3.18.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-nQ0jMKkPOa+kj6Ygz8+kYhBua/vgNTLkd+4r8NSmk7v+Zs78lKnx3T//kEzS0yik6Q6QwGfokwrTcA1Jii2xSw=="],
"sst-linux-arm64": ["sst-linux-arm64@3.17.23", "", { "os": "linux", "cpu": "arm64" }, "sha512-TjtNqgIh7RlAWgPLFCAt0mXvIB+J7WjmRvIRrAdX0mXsndOiBJ/DMOgXSLVsIWHCfPj8MIEot/hWpnJgXgIeag=="], "sst-linux-arm64": ["sst-linux-arm64@3.18.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-mj9VNj3SvLS+HaXx2PhCX0aTA7CwJNoM6JhRc0s/zCilqchcvqDjbhpYBJO4brEPv6aOaaa7T3WvIQqtYauK4Q=="],
"sst-linux-x64": ["sst-linux-x64@3.17.23", "", { "os": "linux", "cpu": "x64" }, "sha512-qdqJiEbYfCjZlI3F/TA6eoIU7JXVkEEI/UMILNf2JWhky0KQdCW2Xyz+wb6c0msVJCWdUM/uj+1DaiP2eXvghw=="], "sst-linux-x64": ["sst-linux-x64@3.18.10", "", { "os": "linux", "cpu": "x64" }, "sha512-7iy1Eq2eqnT9Ag/8OVgC04vRjV7AAQyf/BvzLc+6Sz+GvRiKA8VEuPnbXNYQF+NIvEqsawfcd7MknSTtImpsvQ=="],
"sst-linux-x86": ["sst-linux-x86@3.17.23", "", { "os": "linux", "cpu": "none" }, "sha512-aGmUujIvoNlmAABEGsOgfY1rxD9koC6hN8bnTLbDI+oI/u/zjHYh50jsbL0p3TlaHpwF/lxP3xFSuT6IKp+KgA=="], "sst-linux-x86": ["sst-linux-x86@3.18.10", "", { "os": "linux", "cpu": "none" }, "sha512-77qZSuPZeQ5bdRCiq1pQEdY8EcGNHboKrx4P2yFid2FBDKJsXxOXtIxJdloyx+ljBn0+nxl/g040QBmXxdc9tA=="],
"sst-win32-arm64": ["sst-win32-arm64@3.17.23", "", { "os": "win32", "cpu": "arm64" }, "sha512-ZxdkGqYDrrZGz98rijDCN+m5yuCcwD6Bc9/6hubLsvdpNlVorUqzpg801Ec97xSK0nIC9g6pNiRyxAcsQQstUg=="], "sst-win32-arm64": ["sst-win32-arm64@3.18.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-aY+FhMxvYs8crlrKALpLn/kKmud8YQj6LkMHsrOAAIJhfNyxhCja2vrYQaY+bcqdsS5W2LMVcS2hyaMqKXZKcg=="],
"sst-win32-x64": ["sst-win32-x64@3.17.23", "", { "os": "win32", "cpu": "x64" }, "sha512-yc9cor4MS49Ccy2tQCF1tf6M81yLeSGzGL+gjhUxpVKo2pN3bxl3w70eyU/mTXSEeyAmG9zEfbt6FNu4sy5cUA=="], "sst-win32-x64": ["sst-win32-x64@3.18.10", "", { "os": "win32", "cpu": "x64" }, "sha512-rY+yJXOpG+P5xXnaQRpCvBK2zwwLhjzpYidGkp6F+cGgiVdh2Wre/CIQNRaVHr20ncj8lLe/RsHWa9QCNM48jg=="],
"sst-win32-x86": ["sst-win32-x86@3.17.23", "", { "os": "win32", "cpu": "none" }, "sha512-DIp3s54IpNAfdYjSRt6McvkbEPQDMxUu6RUeRAd2C+FcTJgTloon/ghAPQBaDgu2VoVgymjcJARO/XyfKcCLOQ=="], "sst-win32-x86": ["sst-win32-x86@3.18.10", "", { "os": "win32", "cpu": "none" }, "sha512-pq8SmV0pIjBFMY6DraUZ4akyTxHnfjIKCRbBLdMxFUZK8TzA1NK2YdjRt1AwrgXRYGRyctrz/mt4WyO0SMOVQQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@ -3841,19 +3846,19 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="],
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
"tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="], "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
"tedious": ["tedious@19.2.1", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.5", "@types/node": ">=18", "bl": "^6.1.4", "iconv-lite": "^0.7.0", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-pk1Q16Yl62iocuQB+RWbg6rFUFkIyzqOFQ6NfysCltRvQqKwfurgj8v/f2X+CKvDhSL4IJ0cCOfCHDg9PWEEYA=="], "tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="], "terracotta": ["terracotta@1.1.0", "", { "dependencies": { "solid-use": "^0.9.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-kfQciWUBUBgYkXu7gh3CK3FAJng/iqZslAaY08C+k1Hdx17aVEpcFFb/WPaysxAfcupNH3y53s/pc53xxZauww=="],
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="], "terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"text-decoder": ["text-decoder@1.2.4", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-mzlffA3tBNhziEHPK5L5InZg1d/ElNIpJhnhbDRNUtem/edZcJ5zg5FgwKKKOyklxk+6Jt+TrSu83musmvrDlg=="], "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
@ -3961,7 +3966,7 @@
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
"undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici": ["undici@7.22.0", "", {}, "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg=="],
"undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
@ -4127,7 +4132,7 @@
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
@ -4277,8 +4282,6 @@
"@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@azure/core-http/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="], "@azure/core-xml/fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
@ -4299,7 +4302,7 @@
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="], "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-26dQFi76dB8IiN/WKGQOV+yKKTTlRCxQjoi2WLt0kMcH8pvxVyvfdBDkld5GTl7W1qvBpwVOtFcsqktj3fBRpA=="], "@gitlab/gitlab-ai-provider/openai": ["openai@6.22.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-7Yvy17F33Bi9RutWbsaYt5hJEEJ/krRPOrwan+f9aCPuMat1WVsb2VNSII5W1EksKT6fF69TG/xj4XzodK3JZw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@ -4501,7 +4504,9 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="], "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
@ -4509,6 +4514,8 @@
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="], "ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"ai-gateway-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@ -4529,6 +4536,8 @@
"aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="], "aws-sdk/events": ["events@1.1.1", "", {}, "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw=="],
"aws-sdk/xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
@ -4539,6 +4548,10 @@
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
"body-parser/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@ -4573,6 +4586,8 @@
"express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], "express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="],
"express/qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], "fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@ -4581,7 +4596,7 @@
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], "gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"glob/minimatch": ["minimatch@10.2.0", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w=="], "glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@ -4619,8 +4634,6 @@
"mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"mssql/tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
"nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="], "nitro/h3": ["h3@2.0.1-rc.5", "", { "dependencies": { "rou3": "^0.7.9", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-qkohAzCab0nLzXNm78tBjZDvtKMTmtygS8BJLT3VPczAQofdqlFXDPkXdLMJN4r05+xqneG8snZJ0HgkERCZTg=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@ -4649,10 +4662,10 @@
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="], "path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
@ -4709,6 +4722,8 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
"tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@ -4725,6 +4740,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@ -4745,6 +4762,8 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
@ -5005,8 +5024,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="], "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="], "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@ -5015,7 +5032,7 @@
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ai-gateway-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], "ai-gateway-provider/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@ -5035,6 +5052,8 @@
"astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
"babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="], "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.4", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA=="],
"babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="], "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
@ -5069,8 +5088,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], "opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],

1
github/sst-env.d.ts vendored
View file

@ -2,6 +2,7 @@
/* 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" />

View file

@ -145,6 +145,16 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS18"), new sst.Secret("ZEN_MODELS18"),
new sst.Secret("ZEN_MODELS19"), new sst.Secret("ZEN_MODELS19"),
new sst.Secret("ZEN_MODELS20"), new sst.Secret("ZEN_MODELS20"),
new sst.Secret("ZEN_MODELS21"),
new sst.Secret("ZEN_MODELS22"),
new sst.Secret("ZEN_MODELS23"),
new sst.Secret("ZEN_MODELS24"),
new sst.Secret("ZEN_MODELS25"),
new sst.Secret("ZEN_MODELS26"),
new sst.Secret("ZEN_MODELS27"),
new sst.Secret("ZEN_MODELS28"),
new sst.Secret("ZEN_MODELS29"),
new sst.Secret("ZEN_MODELS30"),
] ]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
@ -204,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 }],
}, },
}, },

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=", "x86_64-linux": "sha256-7y6gQyIxyrdp2DaG/0oOEpuL+1n9oa8arUn1CuDiDhA=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=", "aarch64-linux": "sha256-7dnHO2WqQZ9A8cG3EC8p7408YR9n2F5C6DG5rNWHqNY=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=", "aarch64-darwin": "sha256-jxjhnVfE61RVOHaWvDO4mGLk6guQ8jHeXv/pbu5nbaE=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0=" "x86_64-darwin": "sha256-22yM4FEtVxGWRug6H0rKog86Q/cYE3QsADrRbLeJKVQ="
} }
} }

View file

@ -69,10 +69,11 @@
"devDependencies": { "devDependencies": {
"@actions/artifact": "5.0.1", "@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"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.17.23", "sst": "3.18.10",
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {

View file

@ -0,0 +1,15 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await page.locator("main").click({ position: { x: 5, y: 5 } })
await expect(prompt).not.toBeFocused()
await page.keyboard.press("Control+L")
await expect(prompt).toBeFocused()
})

View file

@ -0,0 +1,31 @@
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
const value = await el.getAttribute("aria-expanded")
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
return value === "true"
}
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
await gotoSession()
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
await expect(treeToggle).toBeVisible()
if (await expanded(treeToggle)) await treeToggle.click()
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
await expect(reviewToggle).toBeVisible()
if (await expanded(reviewToggle)) await reviewToggle.click()
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
await expect(page.locator("#review-panel")).toBeVisible()
await page.keyboard.press(`${modKey}+Shift+R`)
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
await expect(page.locator("#review-panel")).toHaveCount(0)
})

View file

@ -0,0 +1,32 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await dialog.getByRole("textbox").first().fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
const tab = page.getByRole("tab", { name: "package.json" }).first()
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press(`${modKey}+W`)
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
})

View file

@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
const key = await target.getAttribute("data-key") const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item") if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
const model = key.split(":").slice(1).join(":") const model = key.split(":").slice(1).join(":")
await input.fill(model) await input.fill(model)
@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]") await page.locator(promptSelector).click()
await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible() await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const dialogAgain = page.getByRole("dialog")
await expect(dialogAgain).toBeVisible()
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
}) })

View file

@ -0,0 +1,22 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
const dt = await page.evaluateHandle((text) => {
const dt = new DataTransfer()
dt.setData("text/plain", text)
return dt
}, `file:${path}`)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
await expect(pill).toBeVisible()
await expect(pill).toHaveAttribute("data-path", path)
})

View file

@ -0,0 +1,30 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
await prompt.click()
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
const dt = await page.evaluateHandle((b64) => {
const dt = new DataTransfer()
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
const file = new File([bytes], "drop.png", { type: "image/png" })
dt.items.add(file)
return dt
}, png)
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
const img = page.locator('img[alt="drop.png"]').first()
await expect(img).toBeVisible()
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
await expect(remove).toBeVisible()
await img.hover()
await remove.click()
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
})

View file

@ -0,0 +1,18 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
await gotoSession()
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("line one")
await page.keyboard.press("Shift+Enter")
await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
await expect(prompt).toContainText("line one")
await expect(prompt).toContainText("line two")
})

View file

@ -0,0 +1,23 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
await gotoSession()
const prompt = page.locator(promptSelector)
const terminal = page.locator(terminalSelector)
await expect(terminal).not.toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).toBeVisible()
await prompt.click()
await page.keyboard.type("/terminal")
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
await page.keyboard.press("Enter")
await expect(terminal).not.toBeVisible()
})

View file

@ -1,35 +1,36 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { I18nProvider } from "@opencode-ai/ui/context"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { 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 { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { MetaProvider } from "@solidjs/meta"
import { PermissionProvider } from "@/context/permission" import { Navigate, Route, Router } from "@solidjs/router"
import { LayoutProvider } from "@/context/layout" import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { normalizeServerUrl, ServerProvider, useServer } from "@/context/server" 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 { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { type ServerConnection, 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 { 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 DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/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" />
@ -57,7 +58,11 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } __OPENCODE__?: {
updaterEnabled?: boolean
deepLinks?: string[]
wsl?: boolean
}
} }
} }
@ -107,30 +112,6 @@ 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>
@ -157,27 +138,19 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) { function ServerKey(props: ParentProps) {
const server = useServer() const server = useServer()
return ( return (
<Show when={server.url} keyed> <Show when={server.key} keyed>
{props.children} {props.children}
</Show> </Show>
) )
} }
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: {
const platform = usePlatform() children?: JSX.Element
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) defaultServer: ServerConnection.Key
const defaultServerUrl = resolveDefaultServerUrl({ servers?: Array<ServerConnection.Any>
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 defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}> <ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>

View file

@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: {
}} }}
modal={false} modal={false}
placement="top-start" placement="top-start"
gutter={8} gutter={4}
> >
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children} {props.children}

View file

@ -1,19 +1,18 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button"
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 { 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 { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useNavigate } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { useGlobalSDK } from "@/context/global-sdk" import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
interface AddRowProps { interface AddRowProps {
@ -89,7 +88,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(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStatus(result.healthy) setStatus(result.healthy)
} }
@ -171,14 +170,13 @@ 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<string, ServerHealth | undefined>, status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
addServer: { addServer: {
url: "", url: "",
adding: false, adding: false,
@ -214,24 +212,25 @@ export function DialogSelectServer() {
}) })
} }
const replaceServer = (original: string, next: string) => { const replaceServer = (original: ServerConnection.Http, next: string) => {
const active = server.url const active = server.key
const nextActive = active === original ? next : active const newConn = server.add(next)
if (!newConn) return
server.add(next) const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
if (nextActive) server.setActive(nextActive) if (nextActive) server.setActive(nextActive)
server.remove(original) server.remove(ServerConnection.key(original))
} }
const items = createMemo(() => { const items = createMemo(() => {
const current = server.url const current = server.current
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) => x === server.url) ?? items()[0]) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
const sortedItems = createMemo(() => { const sortedItems = createMemo(() => {
const list = items() const list = items()
@ -246,17 +245,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[a]) - rank(store.status[b]) const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(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<string, ServerHealth> = {} const results: Record<ServerConnection.Key, ServerHealth> = {}
await Promise.all( await Promise.all(
items().map(async (url) => { items().map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
setStore("status", reconcile(results)) setStore("status", reconcile(results))
@ -269,15 +268,15 @@ export function DialogSelectServer() {
onCleanup(() => clearInterval(interval)) onCleanup(() => clearInterval(interval))
}) })
async function select(value: string, persist?: boolean) { async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
dialog.close() dialog.close()
if (persist) { if (persist) {
server.add(value) server.add(conn.http.url)
navigate("/") navigate("/")
return return
} }
server.setActive(value) server.setActive(ServerConnection.key(conn))
navigate("/") navigate("/")
} }
@ -311,7 +310,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" }) setStore("addServer", { adding: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("addServer", { adding: false }) setStore("addServer", { adding: false })
if (!result.healthy) { if (!result.healthy) {
@ -320,25 +319,25 @@ export function DialogSelectServer() {
} }
resetAdd() resetAdd()
await select(normalized, true) await select({ type: "http", http: { url: normalized } }, true)
} }
async function handleEdit(original: string, value: string) { async function handleEdit(original: ServerConnection.Any, value: string) {
if (store.editServer.busy) return if (store.editServer.busy || original.type !== "http") return
const normalized = normalizeServerUrl(value) const normalized = normalizeServerUrl(value)
if (!normalized) { if (!normalized) {
resetEdit() resetEdit()
return return
} }
if (normalized === original) { if (normalized === original.http.url) {
resetEdit() resetEdit()
return return
} }
setStore("editServer", { busy: true, error: "" }) setStore("editServer", { busy: true, error: "" })
const result = await checkServerHealth(normalized, fetcher) const result = await checkServerHealth({ url: normalized }, fetcher)
setStore("editServer", { busy: false }) setStore("editServer", { busy: false })
if (!result.healthy) { if (!result.healthy) {
@ -366,7 +365,7 @@ export function DialogSelectServer() {
handleAdd(store.addServer.url) handleAdd(store.addServer.url)
} }
const handleEditKey = (event: KeyboardEvent, original: string) => { const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
@ -378,7 +377,7 @@ export function DialogSelectServer() {
handleEdit(original, store.editServer.value) handleEdit(original, store.editServer.value)
} }
async function handleRemove(url: string) { async function handleRemove(url: ServerConnection.Key) {
server.remove(url) server.remove(url)
if ((await platform.getDefaultServerUrl?.()) === url) { if ((await platform.getDefaultServerUrl?.()) === url) {
platform.setDefaultServerUrl?.(null) platform.setDefaultServerUrl?.(null)
@ -390,11 +389,14 @@ 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={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} search={{
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} key={(x) => x.http.url}
onSelect={(x) => { onSelect={(x) => {
if (x) select(x) if (x) select(x)
}} }}
@ -428,7 +430,7 @@ export function DialogSelectServer() {
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} when={store.editServer.id !== i.http.url}
fallback={ fallback={
<EditRow <EditRow
value={store.editServer.value} value={store.editServer.value}
@ -443,12 +445,12 @@ export function DialogSelectServer() {
} }
> >
<ServerRow <ServerRow
url={i} conn={i}
status={store.status[i]} status={store.status[ServerConnection.key(i)]}
dimmed={store.status[i]?.healthy === false} dimmed={store.status[ServerConnection.key(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}> <Show when={defaultUrl() === i.http.url}>
<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>
@ -456,59 +458,63 @@ export function DialogSelectServer() {
} }
/> />
</Show> </Show>
<Show when={store.editServer.id !== i}> <Show when={store.editServer.id !== i.http.url}>
<div class="flex items-center justify-center gap-5 pl-4"> <div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}> <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>
<DropdownMenu> <Show when={i.type === "http"}>
<DropdownMenu.Trigger <DropdownMenu>
as={IconButton} <DropdownMenu.Trigger
icon="dot-grid" as={IconButton}
variant="ghost" icon="dot-grid"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" variant="ghost"
onClick={(e: MouseEvent) => e.stopPropagation()} class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onPointerDown={(e: PointerEvent) => e.stopPropagation()} onClick={(e: MouseEvent) => e.stopPropagation()}
/> onPointerDown={(e: PointerEvent) => e.stopPropagation()}
<DropdownMenu.Portal> />
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Portal>
<DropdownMenu.Item <DropdownMenu.Content class="mt-1">
onSelect={() => { <DropdownMenu.Item
setStore("editServer", { onSelect={() => {
id: i, setStore("editServer", {
value: i, id: i.http.url,
error: "", value: i.http.url,
status: store.status[i]?.healthy, error: "",
}) status: store.status[ServerConnection.key(i)]?.healthy,
}} })
> }}
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> >
</DropdownMenu.Item> <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
<Show when={canDefault() && defaultUrl() !== i}> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => setDefault(i)}> <Show when={canDefault() && defaultUrl() !== i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i.http.url}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")} {language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel> </DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</Show> </DropdownMenu.Content>
<Show when={canDefault() && defaultUrl() === i}> </DropdownMenu.Portal>
<DropdownMenu.Item onSelect={() => setDefault(null)}> </DropdownMenu>
<DropdownMenu.ItemLabel> </Show>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</Show> </Show>
</div> </div>

View file

@ -71,13 +71,13 @@ const kindLabel = (kind: Kind) => {
const kindTextColor = (kind: Kind) => { const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)" if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)" if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-warning-active)" return "color: var(--icon-diff-modified-base)"
} }
const kindDotColor = (kind: Kind) => { const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)" if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)" if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-warning-active)" return "background-color: var(--icon-diff-modified-base)"
} }
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => { const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
@ -447,12 +447,13 @@ export default function FileTree(props: {
}) })
return ( return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <div data-component="filetree" class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1 const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks()) const kind = () => visibleKind(node, kinds(), marks())
const active = () => !!kind() && !node.ignored
return ( return (
<Switch> <Switch>
@ -530,7 +531,37 @@ export default function FileTree(props: {
onClick={() => props.onFileClick?.(node)} onClick={() => props.onFileClick?.(node)}
> >
<div class="w-4 shrink-0" /> <div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" /> <Switch>
<Match when={node.ignored}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style="color: var(--icon-weak-base)"
mono
/>
</Match>
<Match when={active()}>
<FileIcon
node={node}
class="size-4 filetree-icon filetree-icon--mono"
style={kindTextColor(kind()!)}
mono
/>
</Match>
<Match when={!node.ignored}>
<span class="filetree-iconpair size-4">
<FileIcon
node={node}
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>
</Match>
</Switch>
</FileTreeNode> </FileTreeNode>
</FileTreeNodeTooltip> </FileTreeNodeTooltip>
</Match> </Match>

View file

@ -1,5 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks" import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js" import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element" import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@ -26,13 +26,13 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { RadioGroup } from "@opencode-ai/ui/radio-group"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover } from "@/components/dialog-select-model" import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@ -94,7 +94,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const local = useLocal() const local = useLocal()
const files = useFile() const files = useFile()
const prompt = usePrompt() const prompt = usePrompt()
const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length)
const layout = useLayout() const layout = useLayout()
const comments = useComments() const comments = useComments()
const params = useParams() const params = useParams()
@ -105,11 +104,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement let fileInputRef: HTMLInputElement | undefined
let scrollRef!: HTMLDivElement let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement
const mirror = { input: false } const mirror = { input: false }
const inset = 44
const scrollCursorIntoView = () => { const scrollCursorIntoView = () => {
const container = scrollRef const container = scrollRef
@ -119,7 +119,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return if (!editorRef.contains(range.startContainer)) return
const rect = range.getBoundingClientRect() const cursor = getCursorPosition(editorRef)
const length = promptLength(prompt.current().filter((part) => part.type !== "image"))
if (cursor >= length) {
container.scrollTop = container.scrollHeight
return
}
const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect()
if (!rect.height) return if (!rect.height) return
const containerRect = container.getBoundingClientRect() const containerRect = container.getBoundingClientRect()
@ -132,8 +139,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (bottom > container.scrollTop + container.clientHeight - padding) { if (bottom > container.scrollTop + container.clientHeight - inset) {
container.scrollTop = bottom - container.clientHeight + padding container.scrollTop = bottom - container.clientHeight + inset
} }
} }
@ -223,16 +230,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
}) })
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: language.t(EXAMPLES[store.placeholder]),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const MAX_HISTORY = 100 const commentCount = createMemo(() => {
if (store.mode === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
})
const contextItems = createMemo(() => {
const items = prompt.context.items()
if (store.mode !== "shell") return items
return items.filter((item) => !item.comment?.trim())
})
const hasUserPrompt = createMemo(() => {
const sessionID = params.id
if (!sessionID) return false
const messages = sync.data.message[sessionID]
if (!messages) return false
return messages.some((m) => m.role === "user")
})
const [history, setHistory] = persisted( const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]), Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{ createStore<{
@ -250,6 +267,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}), }),
) )
const suggest = createMemo(() => !hasUserPrompt())
const placeholder = createMemo(() =>
promptPlaceholder({
mode: store.mode,
commentCount: commentCount(),
example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "",
suggest: suggest(),
t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
}),
)
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p) const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true) setStore("applyingHistory", true)
@ -280,6 +309,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const isFocused = createFocusSignal(() => editorRef) const isFocused = createFocusSignal(() => editorRef)
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
const pick = () => fileInputRef?.click()
const setMode = (mode: "normal" | "shell") => {
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => editorRef?.focus())
}
const shellModeKey = "mod+shift+x"
const normalModeKey = "mod+shift+e"
command.register("prompt-input", () => [
{
id: "file.attach",
title: language.t("prompt.action.attachFile"),
category: language.t("command.category.file"),
keybind: "mod+u",
disabled: store.mode !== "normal",
onSelect: pick,
},
{
id: "prompt.mode.shell",
title: language.t("command.prompt.mode.shell"),
category: language.t("command.category.session"),
keybind: shellModeKey,
disabled: store.mode === "shell",
onSelect: () => setMode("shell"),
},
{
id: "prompt.mode.normal",
title: language.t("command.prompt.mode.normal"),
category: language.t("command.category.session"),
keybind: normalModeKey,
disabled: store.mode === "normal",
onSelect: () => setMode("normal"),
},
])
const closePopover = () => setStore("popover", null) const closePopover = () => setStore("popover", null)
@ -325,6 +393,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => { createEffect(() => {
params.id params.id
if (params.id) return if (params.id) return
if (!suggest()) return
const interval = setInterval(() => { const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length)
}, 6500) }, 6500)
@ -334,15 +403,10 @@ 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
createEffect(() => { const handleBlur = () => {
if (!isFocused()) closePopover() 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
@ -815,6 +879,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
event.preventDefault()
if (store.mode !== "normal") return
pick()
return
}
if (event.key === "Backspace") { if (event.key === "Backspace") {
const selection = window.getSelection() const selection = window.getSelection()
if (selection && selection.isCollapsed) { if (selection && selection.isCollapsed) {
@ -842,13 +913,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
} }
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState() if (event.key === "Escape") {
if (event.key === "Escape") { if (store.popover) {
setStore("mode", "normal") closePopover()
event.preventDefault() event.preventDefault()
event.stopPropagation()
return return
} }
if (store.mode === "shell") {
setStore("mode", "normal")
event.preventDefault()
event.stopPropagation()
return
}
if (working()) {
abort()
event.preventDefault()
event.stopPropagation()
return
}
if (escBlur()) {
editorRef.blur()
event.preventDefault()
event.stopPropagation()
return
}
}
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal") setStore("mode", "normal")
event.preventDefault() event.preventDefault()
@ -927,17 +1024,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event) handleSubmit(event)
} }
if (event.key === "Escape") {
if (store.popover) {
closePopover()
} else if (working()) {
abort()
}
}
} }
const variants = createMemo(() => ["default", ...local.model.variant.list()])
return ( return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3"> <div class="relative size-full _max-h-[320px] flex flex-col gap-0">
<PromptPopover <PromptPopover
popover={store.popover} popover={store.popover}
setSlashPopoverRef={(el) => (slashPopoverRef = el)} setSlashPopoverRef={(el) => (slashPopoverRef = el)}
@ -957,8 +1049,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSubmit={handleSubmit} onSubmit={handleSubmit}
classList={{ classList={{
"group/prompt-input": true, "group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true,
"rounded-[14px] overflow-clip focus-within:shadow-xs-border": 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,
}} }}
@ -968,7 +1060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/> />
<PromptContextItems <PromptContextItems
items={prompt.context.items()} items={contextItems()}
active={(item) => { active={(item) => {
const active = comments.active() const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file return !!item.commentID && item.commentID === active?.id && item.path === active?.file
@ -988,162 +1080,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onRemove={removeImageAttachment} onRemove={removeImageAttachment}
removeLabel={language.t("prompt.attachment.remove")} removeLabel={language.t("prompt.attachment.remove")}
/> />
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}> <div
<div class="relative"
data-component="prompt-input" onMouseDown={(e) => {
ref={(el) => { const target = e.target
editorRef = el if (!(target instanceof HTMLElement)) return
props.ref?.(el) if (
}} target.closest(
role="textbox" '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
aria-multiline="true" )
aria-label={placeholder()} ) {
contenteditable="true" return
autocapitalize="off" }
autocorrect="off" editorRef?.focus()
spellcheck={false} }}
onInput={handleInput} >
onPaste={handlePaste} <div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
onCompositionStart={() => setComposing(true)} <div
onCompositionEnd={() => setComposing(false)} data-component="prompt-input"
onKeyDown={handleKeyDown} ref={(el) => {
classList={{ editorRef = el
"select-text": true, props.ref?.(el)
"w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, }}
"[&_[data-type=file]]:text-syntax-property": true, role="textbox"
"[&_[data-type=agent]]:text-syntax-type": true, aria-multiline="true"
"font-mono!": store.mode === "shell", aria-label={placeholder()}
}} contenteditable="true"
/> autocapitalize="off"
<Show when={!prompt.dirty()}> autocorrect="off"
<div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> spellcheck={false}
{placeholder()} onInput={handleInput}
</div> onPaste={handlePaste}
</Show> onCompositionStart={() => setComposing(true)}
</div> onCompositionEnd={() => setComposing(false)}
<div class="relative p-3 flex items-center justify-between gap-2"> onBlur={handleBlur}
<div class="flex items-center gap-2 min-w-0 flex-1"> onKeyDown={handleKeyDown}
<Switch> classList={{
<Match when={store.mode === "shell"}> "select-text": true,
<div class="flex items-center gap-2 px-2 h-6"> "w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
<Icon name="console" size="small" class="text-icon-primary" /> "[&_[data-type=file]]:text-syntax-property": true,
<span class="text-12-regular text-text-primary">{language.t("prompt.mode.shell")}</span> "[&_[data-type=agent]]:text-syntax-type": true,
<span class="text-12-regular text-text-weak">{language.t("prompt.mode.shell.exit")}</span> "font-mono!": store.mode === "shell",
</div> }}
</Match> />
<Match when={store.mode === "normal"}> <Show when={!prompt.dirty()}>
<TooltipKeybind <div
placement="top" class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
gutter={8} classList={{ "font-mono!": store.mode === "shell" }}
title={language.t("command.agent.cycle")} >
keybind={command.keybind("agent.cycle")} {placeholder()}
> </div>
<Select </Show>
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
valueClass="truncate"
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
class="px-2 min-w-0 max-w-[240px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="model-variant-cycle"
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? language.t("common.default")}
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</Show>
</Match>
</Switch>
</div> </div>
<div class="flex items-center gap-1 shrink-0">
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -1155,54 +1145,248 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
<div class="flex items-center gap-1 mr-1">
<SessionContextUsage /> <div
<Show when={store.mode === "normal"}> aria-hidden={store.mode !== "normal"}
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}> class="flex items-center gap-1 transition-all duration-200 ease-out"
<Button classList={{
type="button" "opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
variant="ghost" "opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
class="size-6 px-1" }}
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
</div>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
> >
<IconButton <TooltipKeybind
type="submit" placement="top"
disabled={!prompt.dirty() && !working() && commentCount() === 0} title={language.t("prompt.action.attachFile")}
icon={working() ? "stop" : "arrow-up"} keybind={command.keybind("file.attach")}
variant="primary" >
class="h-6 w-4.5" <Button
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} data-action="prompt-attach"
/> type="button"
</Tooltip> variant="ghost"
class="size-8 p-0"
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
value={
<Switch>
<Match when={working()}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.stop")}</span>
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
</div>
</Match>
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
</div>
</Match>
</Switch>
}
>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div> </div>
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
<div class="pointer-events-none absolute bottom-2 left-2">
<div class="pointer-events-auto">
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
<Button
data-action="prompt-permissions"
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
aria-label={
permission.isAutoAccepting(params.id!, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable")
}
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
</div> </div>
</form> </form>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<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-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">
<Show when={store.mode === "shell"}>
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
<div class="size-4 shrink-0" />
</div>
</Show>
<Show when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular group"
style={{ height: "28px" }}
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: { height: "28px" },
class: "min-w-0 max-w-[320px] text-13-regular group",
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()!.provider.id as IconName}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
class="capitalize max-w-[160px]"
valueClass="truncate text-13-regular"
triggerStyle={{ height: "28px" }}
variant="ghost"
/>
</TooltipKeybind>
</Show>
</div>
<div class="shrink-0" data-component="prompt-mode-toggle">
<RadioGroup
options={["shell", "normal"] as const}
current={store.mode}
value={(mode) => mode}
label={(mode) => (
<TooltipKeybind
placement="top"
gutter={4}
openDelay={2000}
title={language.t(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"
>
<Icon
name={mode === "shell" ? "console" : "prompt"}
class="size-[18px]"
classList={{
"text-icon-strong-base": store.mode === mode,
"text-icon-weak": store.mode !== mode,
}}
/>
</TooltipKeybind>
)}
onSelect={(mode) => mode && setMode(mode)}
fill
pad="none"
class="w-[68px]"
/>
</div>
</div>
</div>
</Show>
</div> </div>
) )
} }

View file

@ -41,10 +41,9 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
> >
<div <div
classList={{ classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 cursor-default transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected, "hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": "bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": selected,
selected,
"bg-background-stronger": !selected, "bg-background-stronger": !selected,
}} }}
onClick={() => props.openComment(item)} onClick={() => props.openComment(item)}

View file

@ -9,27 +9,40 @@ describe("promptPlaceholder", () => {
mode: "shell", mode: "shell",
commentCount: 0, commentCount: 0,
example: "example", example: "example",
suggest: true,
t, t,
}) })
expect(value).toBe("prompt.placeholder.shell") expect(value).toBe("prompt.placeholder.shell")
}) })
test("returns summarize placeholders for comment context", () => { test("returns summarize placeholders for comment context", () => {
expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComment", "prompt.placeholder.summarizeComment",
) )
expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe(
"prompt.placeholder.summarizeComments", "prompt.placeholder.summarizeComments",
) )
}) })
test("returns default placeholder with example", () => { test("returns default placeholder with example when suggestions enabled", () => {
const value = promptPlaceholder({ const value = promptPlaceholder({
mode: "normal", mode: "normal",
commentCount: 0, commentCount: 0,
example: "translated-example", example: "translated-example",
suggest: true,
t, t,
}) })
expect(value).toBe("prompt.placeholder.normal:translated-example") expect(value).toBe("prompt.placeholder.normal:translated-example")
}) })
test("returns simple placeholder when suggestions disabled", () => {
const value = promptPlaceholder({
mode: "normal",
commentCount: 0,
example: "translated-example",
suggest: false,
t,
})
expect(value).toBe("prompt.placeholder.simple")
})
}) })

View file

@ -2,6 +2,7 @@ type PromptPlaceholderInput = {
mode: "normal" | "shell" mode: "normal" | "shell"
commentCount: number commentCount: number
example: string example: string
suggest: boolean
t: (key: string, params?: Record<string, string>) => string t: (key: string, params?: Record<string, string>) => string
} }
@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) {
if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.mode === "shell") return input.t("prompt.placeholder.shell")
if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
if (!input.suggest) return input.t("prompt.placeholder.simple")
return input.t("prompt.placeholder.normal", { example: input.example }) return input.t("prompt.placeholder.normal", { example: input.example })
} }

View file

@ -40,9 +40,9 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
ref={(el) => { ref={(el) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) if (props.popover === "slash") props.setSlashPopoverRef(el)
}} }}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>

View file

@ -12,24 +12,27 @@ 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) => {
session: { createdClients.push(directory)
create: async () => { return {
createdSessions.push(directory) session: {
return { data: { id: `session-${createdSessions.length}` } } create: async () => {
createdSessions.push(directory)
return { data: { id: `session-${createdSessions.length}` } }
},
shell: async () => {
sentShell.push(directory)
return { data: undefined }
},
prompt: async () => ({ data: undefined }),
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
}, },
shell: async () => { worktree: {
sentShell.push(directory) create: async () => ({ data: { directory: `${directory}/new` } }),
return { data: undefined }
}, },
prompt: async () => ({ data: undefined }), }
command: async () => ({ data: undefined }), }
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
})
beforeAll(async () => { beforeAll(async () => {
const rootClient = clientFor("/repo/main") const rootClient = clientFor("/repo/main")
@ -88,11 +91,17 @@ beforeAll(async () => {
})) }))
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => ({ useSDK: () => {
directory: "/repo/main", const sdk = {
client: rootClient, directory: "/repo/main",
url: "http://localhost:4096", client: rootClient,
}), url: "http://localhost:4096",
createClient(opts: any) {
return clientFor(opts.directory)
},
}
return sdk
},
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({

View file

@ -1,21 +1,20 @@
import { Accessor } from "solid-js" import type { Message } from "@opencode-ai/sdk/v2/client"
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 { useLocal } from "@/context/local" import { useNavigate, useParams } from "@solidjs/router"
import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" 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 { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
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 type { FileSelection } from "@/context/file"
import { setCursorPosition } from "./editor-dom"
import { buildRequestParts } from "./build-request-parts" import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
@ -56,7 +55,6 @@ 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()
@ -80,6 +78,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
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
@ -87,6 +86,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
sessionID, sessionID,
}) })
.catch(() => {}) .catch(() => {})
.finally(() => {
globalSync.todo.set(sessionID, undefined)
})
} }
const restoreCommentItems = (items: CommentItem[]) => { const restoreCommentItems = (items: CommentItem[]) => {
@ -171,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
if (sessionDirectory !== projectDirectory) { if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({ client = sdk.createClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory, directory: sessionDirectory,
throwOnError: true, throwOnError: true,
}) })
@ -368,7 +368,10 @@ 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({ status: "failed", message: language.t("workspace.error.stillPreparing") }) resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs) }, timeoutMs)
}) })

View file

@ -1,6 +1,7 @@
import { For, Show, createMemo, type Component } from "solid-js" import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
@ -12,25 +13,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
const language = useLanguage() const language = useLanguage()
const questions = createMemo(() => props.request.questions) const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) const total = createMemo(() => questions().length)
const [store, setStore] = createStore({ const [store, setStore] = createStore({
tab: 0, tab: 0,
answers: [] as QuestionAnswer[], answers: [] as QuestionAnswer[],
custom: [] as string[], custom: [] as string[],
customOn: [] as boolean[],
editing: false, editing: false,
sending: false, sending: false,
}) })
let root: HTMLDivElement | undefined
const question = createMemo(() => questions()[store.tab]) const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? []) const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "") const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true) const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input() const summary = createMemo(() => {
if (!value) return false const n = Math.min(store.tab + 1, total())
return store.answers[store.tab]?.includes(value) ?? false return `${n} of ${total()} questions`
})
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
const prev = input().trim()
const next = value.trim()
setStore("custom", store.tab, value)
if (!selected) return
if (multi()) {
setStore("answers", store.tab, (current = []) => {
const removed = prev ? current.filter((item) => item.trim() !== prev) : current
if (!next) return removed
if (removed.some((item) => item.trim() === next)) return removed
return [...removed, next]
})
return
}
setStore("answers", store.tab, next ? [next] : [])
}
const measure = () => {
if (!root) return
const scroller = document.querySelector(".session-scroller")
const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
const top =
head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
if (!top) {
root.style.removeProperty("--question-prompt-max-height")
return
}
const dock = root.closest('[data-component="session-prompt-dock"]')
if (!(dock instanceof HTMLElement)) return
const dockBottom = dock.getBoundingClientRect().bottom
const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom)
const gap = 8
const max = Math.max(240, Math.floor(dockBottom - top - gap - below))
root.style.setProperty("--question-prompt-max-height", `${max}px`)
}
onMount(() => {
let raf: number | undefined
const update = () => {
if (raf !== undefined) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = undefined
measure()
})
}
update()
window.addEventListener("resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".session-scroller")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf)
})
}) })
const fail = (err: unknown) => { const fail = (err: unknown) => {
@ -64,23 +138,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
} }
const submit = () => { const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
if (custom) { if (!custom) setStore("customOn", store.tab, false)
setStore("custom", store.tab, answer) setStore("editing", false)
}
if (single()) {
void reply([[answer]])
return
}
setStore("tab", store.tab + 1)
} }
const toggle = (answer: string) => { const toggle = (answer: string) => {
@ -90,16 +154,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}) })
} }
const selectTab = (index: number) => { const customToggle = () => {
setStore("tab", index) if (store.sending) return
if (!multi()) {
setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
return
}
const next = !on()
setStore("customOn", store.tab, next)
if (next) {
setStore("editing", true)
customUpdate(input(), true)
return
}
const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false) setStore("editing", false)
} }
const customOpen = () => {
if (store.sending) return
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (store.sending) return if (store.sending) return
if (optIndex === options().length) { if (optIndex === options().length) {
setStore("editing", true) customOpen()
return return
} }
@ -112,176 +201,225 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
pick(opt.label) pick(opt.label)
} }
const handleCustomSubmit = (e: Event) => { const commitCustom = () => {
e.preventDefault() setStore("editing", false)
customUpdate(input())
}
const next = () => {
if (store.sending) return if (store.sending) return
if (store.editing) commitCustom()
const value = input().trim() if (store.tab >= total() - 1) {
if (!value) { submit()
setStore("editing", false)
return return
} }
if (multi()) { setStore("tab", store.tab + 1)
setStore("answers", store.tab, (current = []) => { setStore("editing", false)
if (current.includes(value)) return current }
return [...current, value]
})
setStore("editing", false)
return
}
pick(value, true) const back = () => {
if (store.sending) return
if (store.tab <= 0) return
setStore("tab", store.tab - 1)
setStore("editing", false)
}
const jump = (tab: number) => {
if (store.sending) return
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
} }
return ( return (
<div data-component="question-prompt"> <DockPrompt
<Show when={!single()}> kind="question"
<div data-slot="question-tabs"> ref={(el) => (root = el)}
<For each={questions()}> header={
{(q, index) => { <>
const active = () => index() === store.tab <div data-slot="question-header-title">{summary()}</div>
const answered = () => (store.answers[index()]?.length ?? 0) > 0 <div data-slot="question-progress">
return ( <For each={questions()}>
{(_, i) => (
<button <button
data-slot="question-tab" type="button"
data-active={active()} data-slot="question-progress-segment"
data-answered={answered()} data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={store.sending} disabled={store.sending}
onClick={() => selectTab(index())} onClick={() => jump(i())}
> aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
{q.header} />
</button> )}
)
}}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For> </For>
</div>
</>
}
footer={
<>
<Button variant="ghost" size="large" disabled={store.sending} onClick={reject}>
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
<Show when={store.tab > 0}>
<Button variant="secondary" size="large" disabled={store.sending} onClick={back}>
{language.t("ui.common.back")}
</Button>
</Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
</>
}
>
<div data-slot="question-text">{question()?.question}</div>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
</For>
<Show
when={store.editing}
fallback={
<button <button
data-slot="question-option" data-slot="question-option"
data-picked={customPicked()} data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={store.sending} disabled={store.sending}
onClick={() => selectOption(options().length)} onClick={customOpen}
> >
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> <span
<Show when={!store.editing && input()}> data-slot="question-option-check"
<span data-slot="option-description">{input()}</span> aria-hidden="true"
</Show> onClick={(e) => {
<Show when={customPicked()}> e.preventDefault()
<Icon name="check-small" size="normal" /> e.stopPropagation()
</Show> customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
</span>
</button> </button>
<Show when={store.editing}> }
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}> >
<input <form
ref={(el) => setTimeout(() => el.focus(), 0)} data-slot="question-option"
type="text" data-custom="true"
data-slot="custom-input" data-picked={on()}
placeholder={language.t("ui.question.custom.placeholder")} role={multi() ? "checkbox" : "radio"}
value={input()} aria-checked={on()}
disabled={store.sending} onMouseDown={(e) => {
onInput={(e) => { if (store.sending) {
setStore("custom", store.tab, e.currentTarget.value) e.preventDefault()
}} return
/> }
<Button type="submit" variant="primary" size="small" disabled={store.sending}> if (e.target instanceof HTMLTextAreaElement) return
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")} const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
</Button> if (input instanceof HTMLTextAreaElement) input.focus()
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}} }}
</For> onSubmit={(e) => {
</div> e.preventDefault()
</Show> commitCustom()
}}
<div data-slot="question-actions"> >
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}> <span
{language.t("ui.common.dismiss")} data-slot="question-option-check"
</Button> aria-hidden="true"
<Show when={!single()}> onClick={(e) => {
<Show when={confirm()}> e.preventDefault()
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}> e.stopPropagation()
{language.t("ui.common.submit")} customToggle()
</Button> }}
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
> >
{language.t("ui.common.next")} <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
</Button> <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
</Show> <Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
rows={1}
disabled={store.sending}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
return
}
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}}
/>
</span>
</form>
</Show> </Show>
</div> </div>
</div> </DockPrompt>
) )
} }

View file

@ -1,10 +1,19 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import {
import { serverDisplayName } from "@/context/server" createEffect,
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 {
url: string conn: ServerConnection.Any
status?: ServerHealth status?: ServerHealth
class?: string class?: string
nameClass?: string nameClass?: string
@ -17,7 +26,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.url)) const name = createMemo(() => serverDisplayName(props.conn))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) {
createEffect(() => { createEffect(() => {
name() name()
props.url props.conn.http.url
props.status?.version props.status?.version
queueMicrotask(check) queueMicrotask(check)
}) })

View file

@ -1,5 +1,5 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@ -11,6 +11,7 @@ import { getSessionContextMetrics } from "@/components/session/session-context-m
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
placement?: TooltipProps["placement"]
} }
function openSessionContext(args: { function openSessionContext(args: {
@ -52,6 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
if (tabs().active() === "context") {
tabs().close("context")
return
}
openSessionContext({ openSessionContext({
view: view(), view: view(),
layout, layout,
@ -90,7 +96,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
return ( return (
<Show when={params.id}> <Show when={params.id}>
<Tooltip value={tooltipValue()} placement="top"> <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch> <Switch>
<Match when={variant() === "indicator"}>{circle()}</Match> <Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={true}> <Match when={true}>

View file

@ -0,0 +1,208 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
function dot(status: Todo["status"]) {
if (status !== "in_progress") return undefined
return (
<svg
viewBox="0 0 12 12"
width="12"
height="12"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
class="block"
>
<circle
cx="6"
cy="6"
r="3"
style={{
animation: "var(--animate-pulse-scale)",
"transform-origin": "center",
"transform-box": "fill-box",
}}
/>
</svg>
)
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const toggle = () => setStore("collapsed", (value) => !value)
const summary = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed} of ${total} ${props.title.toLowerCase()} completed`
})
const active = createMemo(
() =>
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos.filter((todo) => todo.status === "completed").at(-1) ??
props.todos[0],
)
const preview = createMemo(() => active()?.content ?? "")
return (
<div
classList={{
"bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true,
"h-[78px]": store.collapsed,
}}
>
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<span class="text-14-regular text-text-strong cursor-default">{summary()}</span>
<Show when={store.collapsed}>
<div class="ml-1 flex-1 min-w-0">
<Show when={preview()}>
<div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div>
</Show>
</div>
</Show>
<div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}>
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<div hidden={store.collapsed}>
<TodoList todos={props.todos} open={!store.collapsed} />
</div>
</div>
)
}
function TodoList(props: { todos: Todo[]; open: boolean }) {
const [stuck, setStuck] = createSignal(false)
const [scrolling, setScrolling] = createSignal(false)
let scrollRef!: HTMLDivElement
let timer: number | undefined
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
const ensure = () => {
if (!props.open) return
if (scrolling()) return
if (!scrollRef || scrollRef.offsetParent === null) return
const el = scrollRef.querySelector("[data-in-progress]")
if (!(el instanceof HTMLElement)) return
const topFade = 16
const bottomFade = 44
const container = scrollRef.getBoundingClientRect()
const rect = el.getBoundingClientRect()
const top = rect.top - container.top + scrollRef.scrollTop
const bottom = rect.bottom - container.top + scrollRef.scrollTop
const viewTop = scrollRef.scrollTop + topFade
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
if (top < viewTop) {
scrollRef.scrollTop = Math.max(0, top - topFade)
} else if (bottom > viewBottom) {
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
}
setStuck(scrollRef.scrollTop > 0)
}
createEffect(
on([() => props.open, inProgress], () => {
if (!props.open || inProgress() < 0) return
requestAnimationFrame(ensure)
}),
)
onCleanup(() => {
if (!timer) return
window.clearTimeout(timer)
})
return (
<div class="relative">
<div
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
ref={scrollRef}
style={{ "overflow-anchor": "none" }}
onScroll={(e) => {
setStuck(e.currentTarget.scrollTop > 0)
setScrolling(true)
if (timer) window.clearTimeout(timer)
timer = window.setTimeout(() => {
setScrolling(false)
if (inProgress() < 0) return
requestAnimationFrame(ensure)
}, 250)
}}
>
<For each={props.todos}>
{(todo) => (
<Checkbox
readOnly
checked={todo.status === "completed"}
indeterminate={todo.status === "in_progress"}
data-in-progress={todo.status === "in_progress" ? "" : undefined}
icon={dot(todo.status)}
style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }}
>
<span
class="text-14-regular min-w-0 break-words"
classList={{
"text-text-weak": todo.status === "completed" || todo.status === "cancelled",
"text-text-strong": todo.status !== "completed" && todo.status !== "cancelled",
}}
style={{
"line-height": "var(--line-height-normal)",
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</Checkbox>
)}
</For>
</div>
<div
class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
style={{
background: "linear-gradient(to bottom, var(--background-base), transparent)",
opacity: stuck() ? 1 : 0,
}}
/>
</div>
)
}

View file

@ -5,6 +5,7 @@ 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"
@ -16,13 +17,6 @@ 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)",
@ -91,11 +85,45 @@ function RawMessage(props: {
) )
} }
export function SessionContextTab(props: SessionContextTabProps) { const emptyMessages: Message[] = []
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(), {
@ -104,7 +132,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(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()))
@ -113,7 +141,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
const all = props.messages() const all = 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 {
@ -124,7 +152,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
}) })
const systemPrompt = createMemo(() => { const systemPrompt = createMemo(() => {
const msg = findLast(props.visibleUserMessages(), (m) => !!m.system) const msg = findLast(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()
@ -146,12 +174,12 @@ export function SessionContextTab(props: SessionContextTabProps) {
const breakdown = createMemo( const breakdown = createMemo(
on( on(
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c?.input) return [] if (!c?.input) return []
return estimateSessionContextBreakdown({ return estimateSessionContextBreakdown({
messages: props.messages(), messages: 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(),
@ -169,7 +197,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
} }
const stats = [ const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" }, { label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, { label: "context.stats.messages", value: () => counts().all.toLocaleString(language.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 },
@ -186,7 +214,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
{ 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(props.info()?.time.created) }, { label: "context.stats.sessionCreated", value: () => formatter().time(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 }[]
@ -199,7 +227,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view()?.scroll("context") const s = 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
@ -220,13 +248,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
pending = undefined pending = undefined
if (!next) return if (!next) return
props.view().setScroll("context", next) view().setScroll("context", next)
}) })
} }
createEffect( createEffect(
on( on(
() => props.messages().length, () => messages().length,
() => { () => {
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
}, },
@ -300,7 +328,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<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={props.messages()}> <For each={messages()}>
{(message) => ( {(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} /> <RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)} )}

View file

@ -257,27 +257,12 @@ 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
@ -311,25 +296,25 @@ export function SessionHeader() {
platform, platform,
}) })
const leftMount = createMemo( const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return ( return (
<> <>
<Show when={leftMount()}> <Show when={centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<button <Button
type="button" type="button"
class="hidden md:flex w-[320px] max-w-full min-w-0 h-[24px] px-2 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-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" variant="ghost"
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")}
> >
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-visible"> <div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
<Icon name="magnifying-glass" size="normal" class="icon-base shrink-0" /> <Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate h-4.5 flex items-center"> <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", { project: name() })} {language.t("session.header.search.placeholder", { project: name() })}
</span> </span>
</div> </div>
@ -339,24 +324,24 @@ 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>
<Show when={rightMount()}> <Show when={rightMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<div class="flex items-center gap-3"> <div class="flex items-center gap-2">
<StatusPopover /> <StatusPopover />
<Show when={projectDirectory()}> <Show when={projectDirectory()}>
<div class="hidden xl:flex items-center"> <div class="hidden xl:flex items-center">
<Show <Show
when={canOpen()} when={canOpen()}
fallback={ fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none" class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath} onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")} aria-label={language.t("session.header.open.copyPath")}
> >
@ -369,10 +354,10 @@ export function SessionHeader() {
} }
> >
<div class="flex items-center"> <div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none" class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={() => openDir(current().id)} onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
> >
@ -381,9 +366,9 @@ export function SessionHeader() {
</div> </div>
<span class="text-12-regular text-text-strong">Open</span> <span class="text-12-regular text-text-strong">Open</span>
</Button> </Button>
<div class="self-stretch w-px bg-border-base/70" /> <div class="self-stretch w-px bg-border-weak-base" />
<DropdownMenu <DropdownMenu
gutter={6} gutter={4}
placement="bottom-end" placement="bottom-end"
open={menu.open} open={menu.open}
onOpenChange={(open) => setMenu("open", open)} onOpenChange={(open) => setMenu("open", open)}
@ -392,7 +377,7 @@ export function SessionHeader() {
as={IconButton} as={IconButton}
icon="chevron-down" icon="chevron-down"
variant="ghost" variant="ghost"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active" class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover"
aria-label={language.t("session.header.open.menu")} aria-label={language.t("session.header.open.menu")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@ -400,7 +385,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={prefs.app} value={current().id}
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)
@ -458,7 +443,7 @@ export function SessionHeader() {
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared") : language.t("session.share.popover.description.unshared")
} }
gutter={6} gutter={4}
placement="bottom-end" placement="bottom-end"
shift={-64} shift={-64}
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden" class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
@ -466,11 +451,11 @@ export function SessionHeader() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", "rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined }, classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={language.t("session.share.action.share")} trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show <Show
@ -539,7 +524,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-base bg-surface-panel shadow-none" class="rounded-l-none h-[24px] border border-border-weak-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={
@ -552,94 +537,97 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Show> </Show>
<div class="flex items-center gap-3 ml-2 shrink-0"> <div class="flex items-center gap-1">
<TooltipKeybind <div class="hidden md:flex items-center gap-1 shrink-0">
title={language.t("command.terminal.toggle")} <TooltipKeybind
keybind={command.keybind("terminal.toggle")} title={language.t("command.terminal.toggle")}
> keybind={command.keybind("terminal.toggle")}
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
> >
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <Button
<Icon variant="ghost"
size="small" class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} onClick={() => view().terminal.toggle()}
class="group-hover/terminal-toggle:hidden" aria-label={language.t("command.terminal.toggle")}
/> aria-expanded={view().terminal.opened()}
<Icon aria-controls="terminal-panel"
size="small" >
name="layout-bottom-partial" <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
class="hidden group-hover/terminal-toggle:inline-block" <Icon
/> size="small"
<Icon name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
size="small" class="group-hover/terminal-toggle:hidden"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} />
class="hidden group-active/terminal-toggle:inline-block" <Icon
/> size="small"
</div> name="layout-bottom-partial"
</Button> class="hidden group-hover/terminal-toggle:inline-block"
</TooltipKeybind> />
</div> <Icon
<div class="hidden lg:block shrink-0"> size="small"
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
<Button class="hidden group-active/terminal-toggle:inline-block"
variant="ghost" />
class="group/review-toggle size-6 p-0" </div>
onClick={() => view().reviewPanel.toggle()} </Button>
aria-label={language.t("command.review.toggle")} </TooltipKeybind>
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel" <TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
> >
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <Button
<Icon variant="ghost"
size="small" class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} onClick={() => view().reviewPanel.toggle()}
class="group-hover/review-toggle:hidden" aria-label={language.t("command.review.toggle")}
/> aria-expanded={view().reviewPanel.opened()}
<Icon aria-controls="review-panel"
size="small" >
name="layout-right-partial" <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
class="hidden group-hover/review-toggle:inline-block" <Icon
/> size="small"
<Icon name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
size="small" class="group-hover/review-toggle:hidden"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} />
class="hidden group-active/review-toggle:inline-block" <Icon
/> size="small"
</div> name="layout-right-partial"
</Button> class="hidden group-hover/review-toggle:inline-block"
</TooltipKeybind> />
</div> <Icon
<div class="hidden lg:block shrink-0"> size="small"
<TooltipKeybind name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
title={language.t("command.fileTree.toggle")} class="hidden group-active/review-toggle:inline-block"
keybind={command.keybind("fileTree.toggle")} />
> </div>
<Button </Button>
variant="ghost" </TooltipKeybind>
class="group/file-tree-toggle size-6 p-0"
onClick={() => layout.fileTree.toggle()} <TooltipKeybind
aria-label={language.t("command.fileTree.toggle")} title={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()} keybind={command.keybind("fileTree.toggle")}
aria-controls="file-tree-panel"
> >
<div class="relative flex items-center justify-center size-4"> <Button
<Icon variant="ghost"
size="small" class="titlebar-icon w-8 h-6 p-0 box-border"
name="bullet-list" onClick={() => layout.fileTree.toggle()}
classList={{ aria-label={language.t("command.fileTree.toggle")}
"text-icon-strong": layout.fileTree.opened(), aria-expanded={layout.fileTree.opened()}
"text-icon-weak": !layout.fileTree.opened(), aria-controls="file-tree-panel"
}} >
/> <div class="relative flex items-center justify-center size-4">
</div> <Icon
</Button> size="small"
</TooltipKeybind> name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div> </div>
</div> </div>
</Portal> </Portal>

View file

@ -9,7 +9,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main" const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create" const CREATE_WORKTREE = "create"
const ROOT_CLASS = const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]" "size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
interface NewSessionViewProps { interface NewSessionViewProps {
worktree: string worktree: string

View file

@ -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 />

View file

@ -1,21 +1,21 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Popover } from "@opencode-ai/ui/popover"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Icon } from "@opencode-ai/ui/icon" 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 { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useNavigate } from "@solidjs/router"
import { useSDK } from "@/context/sdk" import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
import { normalizeServerUrl, useServer } from "@/context/server" import { createStore, reconcile } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
} }
const listServersByHealth = ( const listServersByHealth = (
list: string[], list: ServerConnection.Any[],
active: string | undefined, active: ServerConnection.Key | undefined,
status: Record<string, ServerHealth | undefined>, status: Record<ServerConnection.Key, 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 (a === active) return -1 if (ServerConnection.key(a) === active) return -1
if (b === active) return 1 if (ServerConnection.key(b) === active) return 1
const diff = rank(status[a]) - rank(status[b]) const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(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<string[]>, fetcher: typeof fetch) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
const list = servers() const list = servers()
@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
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 (url) => { list.map(async (conn) => {
results[url] = await checkServerHealth(url, fetcher) results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
}), }),
) )
if (dead) return if (dead) return
@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) =>
return status return status
} }
const useDefaultServerUrl = ( const useDefaultServerKey = (
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,7 +117,14 @@ const useDefaultServerUrl = (
}) })
}) })
return { url, refresh: () => setTick((value) => value + 1) } return {
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: {
@ -163,16 +170,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.url const current = server.current
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.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => item !== current)] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers, fetcher) const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language }) const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) const defaultServer = useDefaultServerKey(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)
@ -196,24 +203,26 @@ export function StatusPopover() {
triggerProps={{ triggerProps={{
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-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-base-active",
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={ trigger={
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-0.5">
<div <div class="size-4 flex items-center justify-center">
classList={{ <div
"size-1.5 rounded-full": true, classList={{
"bg-icon-success-base": overallHealthy(), "size-1.5 rounded-full": true,
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, "bg-icon-success-base": overallHealthy(),
"bg-border-weak-base": server.healthy() === undefined, "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
}} "bg-border-weak-base": server.healthy() === undefined,
/> }}
/>
</div>
<span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span> <span class="text-12-regular text-text-strong">{language.t("status.popover.trigger")}</span>
</div> </div>
} }
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl" class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={6} gutter={4}
placement="bottom-end" placement="bottom-end"
shift={-136} shift={-136}
> >
@ -249,8 +258,9 @@ 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()}>
{(url) => { {(s) => {
const isBlocked = () => health[url]?.healthy === false const key = ServerConnection.key(s)
const isBlocked = () => health[key]?.healthy === false
return ( return (
<button <button
type="button" type="button"
@ -262,19 +272,19 @@ export function StatusPopover() {
aria-disabled={isBlocked()} aria-disabled={isBlocked()}
onClick={() => { onClick={() => {
if (isBlocked()) return if (isBlocked()) return
server.setActive(url) server.setActive(key)
navigate("/") navigate("/")
}} }}
> >
<ServerRow <ServerRow
url={url} conn={s}
status={health[url]} status={health[key]}
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={url === defaultServer.url()}> <Show when={key === defaultServer.key()}>
<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>
@ -282,7 +292,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={url === server.url}> <Show when={server.current && key === ServerConnection.key(server.current)}>
<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>

View file

@ -1,14 +1,15 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { 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 { parseKeybind, matchKeybind } from "@/context/command" import type { LocalPTY } from "@/context/terminal"
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"
@ -106,8 +107,14 @@ 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, { capture: true }) input.container.addEventListener("click", input.handleLinkClick, {
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) 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)
@ -148,6 +155,7 @@ 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
@ -346,7 +354,7 @@ export const Terminal = (props: TerminalProps) => {
} }
ghostty = g ghostty = g
term = t term = t
output = terminalWriter((data) => t.write(data)) output = terminalWriter((data, done) => t.write(data, done))
t.attachCustomKeyEventHandler((event) => { t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) useTerminalUiBindings({
container,
term: t,
cleanups,
handlePointerDown,
handleLinkClick,
})
focusTerminal() focusTerminal()
@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => {
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:"
if (window.__OPENCODE__?.serverPassword) { url.username = server.current?.http.username ?? ""
url.username = "opencode" url.password = server.current?.http.password ?? ""
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
@ -520,9 +532,19 @@ export const Terminal = (props: TerminalProps) => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup() const finalize = () => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
}
if (!output) {
finalize()
return
}
output.flush(finalize)
}) })
return ( return (

View file

@ -1,6 +1,6 @@
import { createEffect, createMemo, Show, untrack } from "solid-js" import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocation, useNavigate } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@ -43,6 +43,7 @@ export function Titlebar() {
const theme = useTheme() const theme = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const params = useParams()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@ -171,9 +172,10 @@ export function Titlebar() {
<IconButton <IconButton
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="size-8 rounded-md" class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle} onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")} aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/> />
</div> </div>
</Show> </Show>
@ -182,13 +184,14 @@ export function Titlebar() {
<IconButton <IconButton
icon="menu" icon="menu"
variant="ghost" variant="ghost"
class="size-8 rounded-md" class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle} onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")} aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/> />
</div> </div>
</Show> </Show>
<div class="flex items-center gap-3 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"} class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom" placement="bottom"
@ -197,7 +200,7 @@ export function Titlebar() {
> >
<Button <Button
variant="ghost" variant="ghost"
class="group/sidebar-toggle size-6 p-0" class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle} onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")} aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()} aria-expanded={layout.sidebar.opened()}
@ -205,56 +208,77 @@ export function Titlebar() {
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon <Icon
size="small" size="small"
name={layout.sidebar.opened() ? "layout-left-full" : "layout-left"} name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
class="group-hover/sidebar-toggle:hidden" class="group-hover/sidebar-toggle:hidden"
/> />
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" /> <Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
<Icon <Icon
size="small" size="small"
name={layout.sidebar.opened() ? "layout-left" : "layout-left-full"} name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
class="hidden group-active/sidebar-toggle:inline-block" class="hidden group-active/sidebar-toggle:inline-block"
/> />
</div> </div>
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<div class="hidden xl:flex items-center gap-1 shrink-0"> <div class="hidden xl:flex items-center shrink-0">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> <Show when={params.dir}>
<Button <TooltipKeybind
variant="ghost" placement="bottom"
icon="arrow-left" title={language.t("command.session.new")}
class="size-6 p-0" keybind={command.keybind("session.new")}
disabled={!canBack()} openDelay={2000}
onClick={back} >
aria-label={language.t("common.goBack")} <Button
/> variant="ghost"
</Tooltip> icon="new-session"
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> class="titlebar-icon w-8 h-6 p-0 box-border"
<Button onClick={() => {
variant="ghost" if (!params.dir) return
icon="arrow-right" navigate(`/${params.dir}/session`)
class="size-6 p-0" }}
disabled={!canForward()} aria-label={language.t("command.session.new")}
onClick={forward} />
aria-label={language.t("common.goForward")} </TooltipKeybind>
/> </Show>
</Tooltip> <div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</div> </div>
</div> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" /> <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div> </div>
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"> <div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" /> <div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div> </div>
<div <div
classList={{ classList={{
"flex items-center min-w-0 justify-end": true, "flex items-center min-w-0 justify-end": true,
"pr-6": !windows(), "pr-2": !windows(),
}} }}
onMouseDown={drag} onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
<div class="w-6 shrink-0" /> <div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />

View file

@ -11,7 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"]) const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
function actionId(id: string) { function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id if (!id.startsWith(SUGGESTED_PREFIX)) return id

View file

@ -1,10 +1,16 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import 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 { createSdkForServer } from "@/utils/server"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server" import { useServer } from "./server"
const abortError = z.object({
name: z.literal("AbortError"),
})
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK", name: "GlobalSDK",
init: () => { init: () => {
@ -12,20 +18,10 @@ 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) return if (!platform.fetch || !server.current) return
try { try {
const url = new URL(server.url) const url = new URL(server.current.http.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 {
@ -33,11 +29,13 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})() })()
const eventSdk = createOpencodeClient({ const currentServer = server.current
baseUrl: server.url, if (!currentServer) throw new Error("No server available")
const eventSdk = createSdkForServer({
signal: abort.signal, signal: abort.signal,
fetch: eventFetch, fetch: eventFetch,
headers: eventFetch ? undefined : auth, server: currentServer.http,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
@ -93,23 +91,51 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
let streamErrorLogged = false let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
void (async () => { void (async () => {
while (!abort.signal.aborted) { while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try { try {
const events = await eventSdk.global.event({ const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => { onSseError: (error) => {
if (aborted(error)) return
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: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
}, },
}) })
let yielded = Date.now() let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) { for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false streamErrorLogged = false
const directory = event.directory ?? "global" const directory = event.directory ?? "global"
const payload = event.payload const payload = event.payload
@ -130,14 +156,18 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
await wait(0) await wait(0)
} }
} catch (error) { } catch (error) {
if (!streamErrorLogged) { if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true streamErrorLogged = true
console.error("[global-sdk] event stream failed", { console.error("[global-sdk] event stream failed", {
url: server.url, url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview", fetch: eventFetch ? "platform" : "webview",
error, error,
}) })
} }
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
} }
if (abort.signal.aborted) return if (abort.signal.aborted) return
@ -145,17 +175,43 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
} }
})().finally(flush) })().finally(flush)
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => { onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
abort.abort() abort.abort()
flush() flush()
}) })
const sdk = createOpencodeClient({ const sdk = createSdkForServer({
baseUrl: server.url, server: server.current.http,
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { url: server.url, client: sdk, event: emitter } return {
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,
})
},
}
}, },
}) })

View file

@ -30,7 +30,6 @@ 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",
@ -39,20 +38,15 @@ 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",
@ -62,9 +56,6 @@ 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([])
@ -73,7 +64,6 @@ 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)
}) })
}) })

View file

@ -1,46 +1,50 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type Project, Path,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
createOpencodeClient, ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "./global-sdk" import { getFilename } from "@opencode-ai/util/path"
import type { InitError } from "../pages/error"
import { import {
createContext, createContext,
createEffect, createEffect,
untrack,
getOwner, getOwner,
useContext, Match,
onCleanup, onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Switch, Switch,
Match, untrack,
useContext,
} from "solid-js" } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast" import { createStore, produce, reconcile } from "solid-js/store"
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 { createRefreshQueue } from "./global-sync/queue" import type { InitError } from "../pages/error"
import { createChildStoreManager } from "./global-sync/child-store" import { useGlobalSDK } from "./global-sdk"
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 { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
import { sanitizeProject } from "./global-sync/utils" import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { createRefreshQueue } from "./global-sync/queue"
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
import { trimSessions } from "./global-sync/session-trim"
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
error?: InitError error?: InitError
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@ -53,14 +57,6 @@ 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()
@ -68,12 +64,7 @@ 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 stats = { const sdkCache = new Map<string, OpencodeClient>()
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 }>()
@ -87,19 +78,25 @@ function createGlobalSync() {
ready: false, ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value, project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
provider_auth: {}, provider_auth: {},
config: {}, config: {},
reload: undefined, reload: undefined,
}) })
const updateStats = (activeDirectoryStores: number) => { const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!import.meta.env.DEV) return if (!sessionID) return
setDevStats({ if (!todos) {
activeDirectoryStores, setGlobalStore(
evictions: stats.evictions, "session_todo",
loadSessionsFullFetchFallback: stats.loadSessionsFallback, produce((draft) => {
}) delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
} }
const paused = () => untrack(() => globalStore.reload) !== undefined const paused = () => untrack(() => globalStore.reload) !== undefined
@ -112,11 +109,6 @@ 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) => {
@ -132,9 +124,7 @@ 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 = createOpencodeClient({ const sdk = globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory, directory,
throwOnError: true, throwOnError: true,
}) })
@ -174,7 +164,10 @@ 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, { limit: store.limit, permission: store.permission }) const next = trimSessions(store.session, {
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" }))
} }
@ -187,10 +180,6 @@ 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 ?? [])
@ -199,10 +188,17 @@ 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], { limit, permission: store.permission }) const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
permission: store.permission,
})
setStore( setStore(
"sessionTotal", "sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), estimateRootSessionTotal({
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 })
@ -270,6 +266,11 @@ function createGlobalSync() {
setGlobalStore("project", next) setGlobalStore("project", next)
}, },
}) })
if (event.type === "server.connected" || event.type === "global.disposed") {
for (const directory of Object.keys(children.children)) {
queue.push(directory)
}
}
return return
} }
@ -283,6 +284,7 @@ function createGlobalSync() {
store, store,
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory), vcsCache: children.vcsCache.get(directory),
loadLsp: () => { loadLsp: () => {
sdkFor(directory) sdkFor(directory)
@ -306,7 +308,9 @@ 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", { url: globalSDK.url }), connectErrorDescription: language.t("error.globalSync.connectFailed", {
url: globalSDK.url,
}),
requestFailedTitle: language.t("common.requestFailed"), requestFailedTitle: language.t("common.requestFailed"),
setGlobalStore, setGlobalStore,
}) })
@ -353,6 +357,9 @@ function createGlobalSync() {
bootstrap, bootstrap,
updateConfig, updateConfig,
project: projectApi, project: projectApi,
todo: {
set: setSessionTodo,
},
} }
} }

View file

@ -1,25 +1,29 @@
import { import type {
type Config, Config,
type Path, OpencodeClient,
type PermissionRequest, Path,
type Project, PermissionRequest,
type ProviderAuthResponse, Project,
type ProviderListResponse, ProviderAuthResponse,
type QuestionRequest, ProviderListResponse,
createOpencodeClient, QuestionRequest,
Todo,
} 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 { 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 type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeProviderList } from "./utils"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
path: Path path: Path
project: Project[] project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
config: Config config: Config
@ -27,7 +31,7 @@ type GlobalStore = {
} }
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: ReturnType<typeof createOpencodeClient> globalSDK: OpencodeClient
connectErrorTitle: string connectErrorTitle: string
connectErrorDescription: string connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
@ -106,13 +110,13 @@ 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: ReturnType<typeof createOpencodeClient> sdk: OpencodeClient
store: Store<State> store: Store<State>
setStore: SetStoreFunction<State> setStore: SetStoreFunction<State>
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
}) { }) {
input.setStore("status", "loading") if (input.store.status !== "complete") input.setStore("status", "loading")
const blockingRequests = { const blockingRequests = {
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),

View file

@ -17,8 +17,6 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({ const manager = createChildStoreManager({
owner, owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false, isBooting: () => false,
isLoadingSessions: () => false, isLoadingSessions: () => false,
onBootstrap() {}, onBootstrap() {},

View file

@ -17,8 +17,6 @@ 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
@ -102,7 +100,6 @@ 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
} }
@ -120,7 +117,6 @@ 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()
} }
} }
@ -200,7 +196,6 @@ 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]

View file

@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => {
expect(refreshCount).toBe(1) expect(refreshCount).toBe(1)
}) })
test("handles server.connected by triggering refresh", () => {
let refreshCount = 0
applyGlobalEvent({
event: { type: "server.connected" },
project: [],
refresh: () => {
refreshCount += 1
},
setGlobalProject() {},
})
expect(refreshCount).toBe(1)
})
}) })
describe("applyDirectoryEvent", () => { describe("applyDirectoryEvent", () => {

View file

@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
refresh: () => void refresh: () => void
}) { }) {
if (input.event.type === "global.disposed") { if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
input.refresh() input.refresh()
return return
} }
@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
}) })
} }
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) { function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return if (!sessionID) return
const hasAny = const hasAny =
store.message[sessionID] !== undefined || store.message[sessionID] !== undefined ||
@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined || store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined || store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return if (!hasAny) return
setStore( setStore(
produce((draft) => { produce((draft) => {
@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string directory: string
loadLsp: () => void loadLsp: () => void
vcsCache?: VcsCache vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) { }) {
const event = input.event const event = input.event
switch (event.type) { switch (event.type) {
@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}), }),
) )
} }
cleanupSessionCaches(input.store, input.setStore, info.id) cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}), }),
) )
} }
cleanupSessionCaches(input.store, input.setStore, info.id) cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break break
@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": { case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] } const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" })) input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break break
} }
case "session.status": { case "session.status": {

View file

@ -9,7 +9,6 @@ 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,

View file

@ -119,7 +119,6 @@ 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 = {

View file

@ -174,6 +174,10 @@ 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: () => {
@ -184,15 +188,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}), }),
) )
const locale = createMemo<Locale>(() => const locale = createMemo<Locale>(() => normalizeLocale(store.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()])
@ -213,7 +209,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label, label,
t, t,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", next) setStore("locale", normalizeLocale(next))
}, },
} }
}, },

View file

@ -1,9 +1,8 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import 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 { createEffect, createMemo, onCleanup, type Accessor } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup } 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 }>
@ -12,14 +11,11 @@ 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(() =>
createOpencodeClient({ globalSDK.createClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory: directory(), directory: directory(),
throwOnError: true, throwOnError: true,
}), }),
@ -45,6 +41,9 @@ 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)
},
} }
}, },
}) })

View file

@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { type Accessor, 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,92 +15,118 @@ export function normalizeServerUrl(input: string) {
return withProtocol.replace(/\/+$/, "") return withProtocol.replace(/\/+$/, "")
} }
export function serverDisplayName(url: string) { export function serverDisplayName(conn?: ServerConnection.Any) {
if (!url) return "" if (!conn) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "") if (conn.displayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
} }
function projectsKey(url: string) { function projectsKey(key: ServerConnection.Key) {
if (!url) return "" if (!key) return ""
const host = url.replace(/^https?:\/\//, "").split(":")[0] if (key === "sidecar") return "local"
const host = key.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 return key
}
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: { defaultUrl: string; isSidecar?: boolean }) => { init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
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> => [
...(props.servers ?? []),
...store.list.map((value) => ({
type: "http" as const,
http: typeof value === "string" ? { url: value } : value,
})),
],
)
const [state, setState] = createStore({ const [state, setState] = createStore({
active: "", active: props.defaultServer,
healthy: undefined as boolean | undefined, healthy: undefined as boolean | undefined,
}) })
const healthy = () => state.healthy const healthy = () => state.healthy
const defaultUrl = () => normalizeServerUrl(props.defaultUrl) function startHealthPolling(conn: ServerConnection.Any) {
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(url) void check(conn)
.then((next) => { .then((next) => {
if (!alive) return if (!alive) return
setState("healthy", next) setState("healthy", next)
@ -118,59 +144,73 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
} }
} }
function setActive(input: string) { function setActive(input: ServerConnection.Key) {
const url = normalizeServerUrl(input) if (state.active !== input) setState("active", 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
updateServerList(url) return batch(() => {
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(input: string) { function remove(key: ServerConnection.Key) {
const url = normalizeServerUrl(input) const list = store.list.filter((x) => x !== key)
if (!url) return batch(() => {
updateServerList(url, true) setStore("list", list)
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 = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy) const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http, fetcher).then((x) => x.healthy)
createEffect(() => { createEffect(() => {
const url = state.active const current_ = current()
if (!url) return if (!current_) return
setState("healthy", undefined) setState("healthy", undefined)
onCleanup(startHealthPolling(url)) onCleanup(startHealthPolling(current_))
}) })
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 isLocal = createMemo(() => origin() === "local") const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" && c.variant === "base"
})
return { return {
ready: isReady, ready: isReady,
healthy, healthy,
isLocal, isLocal,
get url() { get key() {
return state.active return state.active
}, },
get name() { get name() {
return serverDisplayName(state.active) return serverDisplayName(current())
}, },
get list() { get list() {
return store.list return allServers()
},
get current() {
return current()
}, },
setActive, setActive,
add, add,

View file

@ -289,12 +289,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory const directory = sdk.directory
const client = sdk.client const client = sdk.client
const [store, setStore] = globalSync.child(directory) const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () => return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => { retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" })) const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}), }),
) )
}, },

View file

@ -1,11 +1,14 @@
// @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 { Platform, PlatformProvider } from "@/context/platform" import { type 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"
@ -107,12 +110,22 @@ const platform: Platform = {
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 /> <AppInterface defaultServer={ServerConnection.key(server)} servers={[server]} />
</AppBaseProviders> </AppBaseProviders>
</PlatformProvider> </PlatformProvider>
), ),

View file

@ -63,6 +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.normal": "Prompt",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل", "command.workspace.toggle": "تبديل مساحات العمل",
@ -206,9 +208,11 @@ export const dict = {
"common.attachment": "مرفق", "common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...", "prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.simple": "اسأل أي شيء...",
"prompt.placeholder.summarizeComments": "لخّص التعليقات…", "prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc للخروج", "prompt.mode.shell.exit": "esc للخروج",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية", "prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟", "prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
@ -447,6 +451,9 @@ export const dict = {
"session.messages.loading": "جارٍ تحميل الرسائل...", "session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث", "session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق", "session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.todo.title": "المهام",
"session.todo.collapse": "طي",
"session.todo.expand": "توسيع",
"session.new.worktree.main": "الفرع الرئيسي", "session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})", "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة", "session.new.worktree.create": "إنشاء شجرة عمل جديدة",

View file

@ -63,6 +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.normal": "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",
@ -206,9 +208,11 @@ export const dict = {
"common.attachment": "anexo", "common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...", "prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.simple": "Pergunte qualquer coisa...",
"prompt.placeholder.summarizeComments": "Resumir comentários…", "prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…", "prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para sair", "prompt.mode.shell.exit": "esc para sair",
"prompt.example.1": "Corrigir um TODO no código", "prompt.example.1": "Corrigir um TODO no código",
"prompt.example.2": "Qual é a stack tecnológica deste projeto?", "prompt.example.2": "Qual é a stack tecnológica deste projeto?",
@ -450,6 +454,9 @@ export const dict = {
"session.messages.loading": "Carregando mensagens...", "session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente", "session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto", "session.context.addToContext": "Adicionar {{selection}} ao contexto",
"session.todo.title": "Tarefas",
"session.todo.collapse": "Recolher",
"session.todo.expand": "Expandir",
"session.new.worktree.main": "Branch principal", "session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})", "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree", "session.new.worktree.create": "Criar novo worktree",

View file

@ -69,6 +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.normal": "Prompt",
"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",
@ -224,9 +226,11 @@ export const dict = {
"prompt.placeholder.shell": "Unesi shell naredbu...", "prompt.placeholder.shell": "Unesi shell naredbu...",
"prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"', "prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"',
"prompt.placeholder.simple": "Pitaj bilo šta...",
"prompt.placeholder.summarizeComments": "Sažmi komentare…", "prompt.placeholder.summarizeComments": "Sažmi komentare…",
"prompt.placeholder.summarizeComment": "Sažmi komentar…", "prompt.placeholder.summarizeComment": "Sažmi komentar…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc za izlaz", "prompt.mode.shell.exit": "esc za izlaz",
"prompt.example.1": "Popravi TODO u bazi koda", "prompt.example.1": "Popravi TODO u bazi koda",
@ -505,6 +509,9 @@ export const dict = {
"session.messages.jumpToLatest": "Idi na najnovije", "session.messages.jumpToLatest": "Idi na najnovije",
"session.context.addToContext": "Dodaj {{selection}} u kontekst", "session.context.addToContext": "Dodaj {{selection}} u kontekst",
"session.todo.title": "Zadaci",
"session.todo.collapse": "Sažmi",
"session.todo.expand": "Proširi",
"session.new.worktree.main": "Glavna grana", "session.new.worktree.main": "Glavna grana",
"session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})", "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})",

View file

@ -69,6 +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.normal": "Prompt",
"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",
@ -222,9 +224,11 @@ export const dict = {
"prompt.placeholder.shell": "Indtast shell-kommando...", "prompt.placeholder.shell": "Indtast shell-kommando...",
"prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"',
"prompt.placeholder.simple": "Spørg om hvad som helst...",
"prompt.placeholder.summarizeComments": "Opsummér kommentarer…", "prompt.placeholder.summarizeComments": "Opsummér kommentarer…",
"prompt.placeholder.summarizeComment": "Opsummér kommentar…", "prompt.placeholder.summarizeComment": "Opsummér kommentar…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc for at afslutte", "prompt.mode.shell.exit": "esc for at afslutte",
"prompt.example.1": "Ret en TODO i koden", "prompt.example.1": "Ret en TODO i koden",
@ -500,6 +504,9 @@ export const dict = {
"session.messages.jumpToLatest": "Gå til seneste", "session.messages.jumpToLatest": "Gå til seneste",
"session.context.addToContext": "Tilføj {{selection}} til kontekst", "session.context.addToContext": "Tilføj {{selection}} til kontekst",
"session.todo.title": "Opgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Udvid",
"session.new.worktree.main": "Hovedgren", "session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",

View file

@ -67,6 +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.normal": "Prompt",
"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",
@ -211,9 +213,11 @@ export const dict = {
"common.attachment": "Anhang", "common.attachment": "Anhang",
"prompt.placeholder.shell": "Shell-Befehl eingeben...", "prompt.placeholder.shell": "Shell-Befehl eingeben...",
"prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"',
"prompt.placeholder.simple": "Fragen Sie alles...",
"prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…",
"prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…", "prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc zum Verlassen", "prompt.mode.shell.exit": "esc zum Verlassen",
"prompt.example.1": "Ein TODO in der Codebasis beheben", "prompt.example.1": "Ein TODO in der Codebasis beheben",
"prompt.example.2": "Was ist der Tech-Stack dieses Projekts?", "prompt.example.2": "Was ist der Tech-Stack dieses Projekts?",
@ -458,6 +462,9 @@ export const dict = {
"session.messages.loading": "Lade Nachrichten...", "session.messages.loading": "Lade Nachrichten...",
"session.messages.jumpToLatest": "Zum neuesten springen", "session.messages.jumpToLatest": "Zum neuesten springen",
"session.context.addToContext": "{{selection}} zum Kontext hinzufügen", "session.context.addToContext": "{{selection}} zum Kontext hinzufügen",
"session.todo.title": "Aufgaben",
"session.todo.collapse": "Einklappen",
"session.todo.expand": "Ausklappen",
"session.new.worktree.main": "Haupt-Branch", "session.new.worktree.main": "Haupt-Branch",
"session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})", "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})",
"session.new.worktree.create": "Neuen Worktree erstellen", "session.new.worktree.create": "Neuen Worktree erstellen",

View file

@ -69,6 +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.normal": "Prompt",
"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",
@ -224,9 +226,11 @@ export const dict = {
"prompt.placeholder.shell": "Enter shell command...", "prompt.placeholder.shell": "Enter shell command...",
"prompt.placeholder.normal": 'Ask anything... "{{example}}"', "prompt.placeholder.normal": 'Ask anything... "{{example}}"',
"prompt.placeholder.simple": "Ask anything...",
"prompt.placeholder.summarizeComments": "Summarize comments…", "prompt.placeholder.summarizeComments": "Summarize comments…",
"prompt.placeholder.summarizeComment": "Summarize comment…", "prompt.placeholder.summarizeComment": "Summarize comment…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc to exit", "prompt.mode.shell.exit": "esc to exit",
"prompt.example.1": "Fix a TODO in the codebase", "prompt.example.1": "Fix a TODO in the codebase",
@ -266,7 +270,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file", "prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context", "prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Attach file", "prompt.action.attachFile": "Add file",
"prompt.attachment.remove": "Remove attachment", "prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
@ -504,6 +508,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest", "session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context", "session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch", "session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})", "session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
@ -516,7 +523,7 @@ export const dict = {
"session.header.open.action": "Open {{app}}", "session.header.open.action": "Open {{app}}",
"session.header.open.ariaLabel": "Open in {{app}}", "session.header.open.ariaLabel": "Open in {{app}}",
"session.header.open.menu": "Open options", "session.header.open.menu": "Open options",
"session.header.open.copyPath": "Copy Path", "session.header.open.copyPath": "Copy path",
"status.popover.trigger": "Status", "status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations", "status.popover.ariaLabel": "Server configurations",

View file

@ -69,6 +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.normal": "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",
@ -223,9 +225,11 @@ export const dict = {
"prompt.placeholder.shell": "Introduce comando de shell...", "prompt.placeholder.shell": "Introduce comando de shell...",
"prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"',
"prompt.placeholder.simple": "Pregunta cualquier cosa...",
"prompt.placeholder.summarizeComments": "Resumir comentarios…", "prompt.placeholder.summarizeComments": "Resumir comentarios…",
"prompt.placeholder.summarizeComment": "Resumir comentario…", "prompt.placeholder.summarizeComment": "Resumir comentario…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc para salir", "prompt.mode.shell.exit": "esc para salir",
"prompt.example.1": "Arreglar un TODO en el código", "prompt.example.1": "Arreglar un TODO en el código",
@ -506,6 +510,9 @@ export const dict = {
"session.messages.jumpToLatest": "Ir al último", "session.messages.jumpToLatest": "Ir al último",
"session.context.addToContext": "Añadir {{selection}} al contexto", "session.context.addToContext": "Añadir {{selection}} al contexto",
"session.todo.title": "Tareas",
"session.todo.collapse": "Contraer",
"session.todo.expand": "Expandir",
"session.new.worktree.main": "Rama principal", "session.new.worktree.main": "Rama principal",
"session.new.worktree.mainWithBranch": "Rama principal ({{branch}})", "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})",

View file

@ -63,6 +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.normal": "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",
@ -206,9 +208,11 @@ export const dict = {
"common.attachment": "pièce jointe", "common.attachment": "pièce jointe",
"prompt.placeholder.shell": "Entrez une commande shell...", "prompt.placeholder.shell": "Entrez une commande shell...",
"prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"',
"prompt.placeholder.simple": "Demandez n'importe quoi...",
"prompt.placeholder.summarizeComments": "Résumer les commentaires…", "prompt.placeholder.summarizeComments": "Résumer les commentaires…",
"prompt.placeholder.summarizeComment": "Résumer le commentaire…", "prompt.placeholder.summarizeComment": "Résumer le commentaire…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc pour quitter", "prompt.mode.shell.exit": "esc pour quitter",
"prompt.example.1": "Corriger un TODO dans la base de code", "prompt.example.1": "Corriger un TODO dans la base de code",
"prompt.example.2": "Quelle est la pile technique de ce projet ?", "prompt.example.2": "Quelle est la pile technique de ce projet ?",
@ -456,6 +460,9 @@ export const dict = {
"session.messages.loading": "Chargement des messages...", "session.messages.loading": "Chargement des messages...",
"session.messages.jumpToLatest": "Aller au dernier", "session.messages.jumpToLatest": "Aller au dernier",
"session.context.addToContext": "Ajouter {{selection}} au contexte", "session.context.addToContext": "Ajouter {{selection}} au contexte",
"session.todo.title": "Tâches",
"session.todo.collapse": "Réduire",
"session.todo.expand": "Développer",
"session.new.worktree.main": "Branche principale", "session.new.worktree.main": "Branche principale",
"session.new.worktree.mainWithBranch": "Branche principale ({{branch}})", "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})",
"session.new.worktree.create": "Créer un nouvel arbre de travail", "session.new.worktree.create": "Créer un nouvel arbre de travail",

View file

@ -63,6 +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.normal": "プロンプト",
"command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.enable": "編集を自動承認",
"command.permissions.autoaccept.disable": "編集の自動承認を停止", "command.permissions.autoaccept.disable": "編集の自動承認を停止",
"command.workspace.toggle": "ワークスペースを切り替え", "command.workspace.toggle": "ワークスペースを切り替え",
@ -205,9 +207,11 @@ export const dict = {
"common.attachment": "添付ファイル", "common.attachment": "添付ファイル",
"prompt.placeholder.shell": "シェルコマンドを入力...", "prompt.placeholder.shell": "シェルコマンドを入力...",
"prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"',
"prompt.placeholder.simple": "何でも聞いてください...",
"prompt.placeholder.summarizeComments": "コメントを要約…", "prompt.placeholder.summarizeComments": "コメントを要約…",
"prompt.placeholder.summarizeComment": "コメントを要約…", "prompt.placeholder.summarizeComment": "コメントを要約…",
"prompt.mode.shell": "シェル", "prompt.mode.shell": "シェル",
"prompt.mode.normal": "プロンプト",
"prompt.mode.shell.exit": "escで終了", "prompt.mode.shell.exit": "escで終了",
"prompt.example.1": "コードベースのTODOを修正", "prompt.example.1": "コードベースのTODOを修正",
"prompt.example.2": "このプロジェクトの技術スタックは何ですか?", "prompt.example.2": "このプロジェクトの技術スタックは何ですか?",
@ -448,6 +452,9 @@ export const dict = {
"session.messages.loading": "メッセージを読み込み中...", "session.messages.loading": "メッセージを読み込み中...",
"session.messages.jumpToLatest": "最新へジャンプ", "session.messages.jumpToLatest": "最新へジャンプ",
"session.context.addToContext": "{{selection}}をコンテキストに追加", "session.context.addToContext": "{{selection}}をコンテキストに追加",
"session.todo.title": "ToDo",
"session.todo.collapse": "折りたたむ",
"session.todo.expand": "展開",
"session.new.worktree.main": "メインブランチ", "session.new.worktree.main": "メインブランチ",
"session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})", "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})",
"session.new.worktree.create": "新しいワークツリーを作成", "session.new.worktree.create": "新しいワークツリーを作成",

View file

@ -67,6 +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.normal": "프롬프트",
"command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.enable": "편집 자동 수락",
"command.permissions.autoaccept.disable": "편집 자동 수락 중지", "command.permissions.autoaccept.disable": "편집 자동 수락 중지",
"command.workspace.toggle": "작업 공간 전환", "command.workspace.toggle": "작업 공간 전환",
@ -209,9 +211,11 @@ export const dict = {
"common.attachment": "첨부 파일", "common.attachment": "첨부 파일",
"prompt.placeholder.shell": "셸 명령어 입력...", "prompt.placeholder.shell": "셸 명령어 입력...",
"prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"',
"prompt.placeholder.simple": "무엇이든 물어보세요...",
"prompt.placeholder.summarizeComments": "댓글 요약…", "prompt.placeholder.summarizeComments": "댓글 요약…",
"prompt.placeholder.summarizeComment": "댓글 요약…", "prompt.placeholder.summarizeComment": "댓글 요약…",
"prompt.mode.shell": "셸", "prompt.mode.shell": "셸",
"prompt.mode.normal": "프롬프트",
"prompt.mode.shell.exit": "종료하려면 esc", "prompt.mode.shell.exit": "종료하려면 esc",
"prompt.example.1": "코드베이스의 TODO 수정", "prompt.example.1": "코드베이스의 TODO 수정",
"prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?", "prompt.example.2": "이 프로젝트의 기술 스택이 무엇인가요?",
@ -450,6 +454,9 @@ export const dict = {
"session.messages.loading": "메시지 로드 중...", "session.messages.loading": "메시지 로드 중...",
"session.messages.jumpToLatest": "최신으로 이동", "session.messages.jumpToLatest": "최신으로 이동",
"session.context.addToContext": "컨텍스트에 {{selection}} 추가", "session.context.addToContext": "컨텍스트에 {{selection}} 추가",
"session.todo.title": "할 일",
"session.todo.collapse": "접기",
"session.todo.expand": "펼치기",
"session.new.worktree.main": "메인 브랜치", "session.new.worktree.main": "메인 브랜치",
"session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})", "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})",
"session.new.worktree.create": "새 작업 트리 생성", "session.new.worktree.create": "새 작업 트리 생성",

View file

@ -72,6 +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.normal": "Prompt",
"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",
@ -226,9 +228,11 @@ export const dict = {
"prompt.placeholder.shell": "Skriv inn shell-kommando...", "prompt.placeholder.shell": "Skriv inn shell-kommando...",
"prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"',
"prompt.placeholder.simple": "Spør om hva som helst...",
"prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…",
"prompt.placeholder.summarizeComment": "Oppsummer kommentar…", "prompt.placeholder.summarizeComment": "Oppsummer kommentar…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "ESC for å avslutte", "prompt.mode.shell.exit": "ESC for å avslutte",
"prompt.example.1": "Fiks en TODO i kodebasen", "prompt.example.1": "Fiks en TODO i kodebasen",
@ -506,6 +510,9 @@ export const dict = {
"session.messages.jumpToLatest": "Hopp til nyeste", "session.messages.jumpToLatest": "Hopp til nyeste",
"session.context.addToContext": "Legg til {{selection}} i kontekst", "session.context.addToContext": "Legg til {{selection}} i kontekst",
"session.todo.title": "Oppgaver",
"session.todo.collapse": "Skjul",
"session.todo.expand": "Utvid",
"session.new.worktree.main": "Hovedgren", "session.new.worktree.main": "Hovedgren",
"session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})",

View file

@ -63,6 +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.normal": "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",
@ -207,9 +209,11 @@ export const dict = {
"common.attachment": "załącznik", "common.attachment": "załącznik",
"prompt.placeholder.shell": "Wpisz polecenie terminala...", "prompt.placeholder.shell": "Wpisz polecenie terminala...",
"prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"',
"prompt.placeholder.simple": "Zapytaj o cokolwiek...",
"prompt.placeholder.summarizeComments": "Podsumuj komentarze…", "prompt.placeholder.summarizeComments": "Podsumuj komentarze…",
"prompt.placeholder.summarizeComment": "Podsumuj komentarz…", "prompt.placeholder.summarizeComment": "Podsumuj komentarz…",
"prompt.mode.shell": "Terminal", "prompt.mode.shell": "Terminal",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "esc aby wyjść", "prompt.mode.shell.exit": "esc aby wyjść",
"prompt.example.1": "Napraw TODO w bazie kodu", "prompt.example.1": "Napraw TODO w bazie kodu",
"prompt.example.2": "Jaki jest stos technologiczny tego projektu?", "prompt.example.2": "Jaki jest stos technologiczny tego projektu?",
@ -449,6 +453,9 @@ export const dict = {
"session.messages.loading": "Ładowanie wiadomości...", "session.messages.loading": "Ładowanie wiadomości...",
"session.messages.jumpToLatest": "Przejdź do najnowszych", "session.messages.jumpToLatest": "Przejdź do najnowszych",
"session.context.addToContext": "Dodaj {{selection}} do kontekstu", "session.context.addToContext": "Dodaj {{selection}} do kontekstu",
"session.todo.title": "Zadania",
"session.todo.collapse": "Zwiń",
"session.todo.expand": "Rozwiń",
"session.new.worktree.main": "Główna gałąź", "session.new.worktree.main": "Główna gałąź",
"session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})", "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})",
"session.new.worktree.create": "Utwórz nowe drzewo robocze", "session.new.worktree.create": "Utwórz nowe drzewo robocze",

View file

@ -69,6 +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.normal": "Промпт",
"command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.enable": "Авто-принятие изменений",
"command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
"command.workspace.toggle": "Переключить рабочие пространства", "command.workspace.toggle": "Переключить рабочие пространства",
@ -223,9 +225,11 @@ export const dict = {
"prompt.placeholder.shell": "Введите команду оболочки...", "prompt.placeholder.shell": "Введите команду оболочки...",
"prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"',
"prompt.placeholder.simple": "Спросите что угодно...",
"prompt.placeholder.summarizeComments": "Суммировать комментарии…", "prompt.placeholder.summarizeComments": "Суммировать комментарии…",
"prompt.placeholder.summarizeComment": "Суммировать комментарий…", "prompt.placeholder.summarizeComment": "Суммировать комментарий…",
"prompt.mode.shell": "Оболочка", "prompt.mode.shell": "Оболочка",
"prompt.mode.normal": "Промпт",
"prompt.mode.shell.exit": "esc для выхода", "prompt.mode.shell.exit": "esc для выхода",
"prompt.example.1": "Исправить TODO в коде", "prompt.example.1": "Исправить TODO в коде",
@ -504,6 +508,9 @@ export const dict = {
"session.messages.jumpToLatest": "Перейти к последнему", "session.messages.jumpToLatest": "Перейти к последнему",
"session.context.addToContext": "Добавить {{selection}} в контекст", "session.context.addToContext": "Добавить {{selection}} в контекст",
"session.todo.title": "Задачи",
"session.todo.collapse": "Свернуть",
"session.todo.expand": "Развернуть",
"session.new.worktree.main": "Основная ветка", "session.new.worktree.main": "Основная ветка",
"session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})", "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})",

View file

@ -69,6 +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.normal": "พรอมต์",
"command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
"command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
"command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.workspace.toggle": "สลับพื้นที่ทำงาน",
@ -223,9 +225,11 @@ export const dict = {
"prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...", "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
"prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"', "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
"prompt.placeholder.simple": "ถามอะไรก็ได้...",
"prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…", "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
"prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…", "prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
"prompt.mode.shell": "เชลล์", "prompt.mode.shell": "เชลล์",
"prompt.mode.normal": "พรอมต์",
"prompt.mode.shell.exit": "กด esc เพื่อออก", "prompt.mode.shell.exit": "กด esc เพื่อออก",
"prompt.example.1": "แก้ไข TODO ในโค้ดเบส", "prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
@ -501,6 +505,9 @@ export const dict = {
"session.messages.jumpToLatest": "ไปที่ล่าสุด", "session.messages.jumpToLatest": "ไปที่ล่าสุด",
"session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท", "session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
"session.todo.title": "สิ่งที่ต้องทำ",
"session.todo.collapse": "ย่อ",
"session.todo.expand": "ขยาย",
"session.new.worktree.main": "สาขาหลัก", "session.new.worktree.main": "สาขาหลัก",
"session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})", "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",

View file

@ -93,6 +93,9 @@ 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.normal": "Prompt",
"command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.enable": "自动接受编辑",
"command.permissions.autoaccept.disable": "停止自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑",
@ -244,9 +247,11 @@ export const dict = {
"prompt.placeholder.shell": "输入 shell 命令...", "prompt.placeholder.shell": "输入 shell 命令...",
"prompt.placeholder.normal": '随便问点什么... "{{example}}"', "prompt.placeholder.normal": '随便问点什么... "{{example}}"',
"prompt.placeholder.simple": "随便问点什么...",
"prompt.placeholder.summarizeComments": "总结评论…", "prompt.placeholder.summarizeComments": "总结评论…",
"prompt.placeholder.summarizeComment": "总结该评论…", "prompt.placeholder.summarizeComment": "总结该评论…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出", "prompt.mode.shell.exit": "按 esc 退出",
"prompt.example.1": "修复代码库中的一个 TODO", "prompt.example.1": "修复代码库中的一个 TODO",
"prompt.example.2": "这个项目的技术栈是什么?", "prompt.example.2": "这个项目的技术栈是什么?",
@ -500,6 +505,9 @@ export const dict = {
"session.messages.loading": "正在加载消息...", "session.messages.loading": "正在加载消息...",
"session.messages.jumpToLatest": "跳转到最新", "session.messages.jumpToLatest": "跳转到最新",
"session.context.addToContext": "将 {{selection}} 添加到上下文", "session.context.addToContext": "将 {{selection}} 添加到上下文",
"session.todo.title": "待办事项",
"session.todo.collapse": "折叠",
"session.todo.expand": "展开",
"session.new.worktree.main": "主分支", "session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支({{branch}}", "session.new.worktree.mainWithBranch": "主分支({{branch}}",
"session.new.worktree.create": "创建新的 worktree", "session.new.worktree.create": "创建新的 worktree",

View file

@ -73,6 +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.normal": "Prompt",
"command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.enable": "自動接受編輯",
"command.permissions.autoaccept.disable": "停止自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯",
"command.workspace.toggle": "切換工作區", "command.workspace.toggle": "切換工作區",
@ -223,9 +225,11 @@ export const dict = {
"prompt.placeholder.shell": "輸入 shell 命令...", "prompt.placeholder.shell": "輸入 shell 命令...",
"prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"',
"prompt.placeholder.simple": "隨便問點什麼...",
"prompt.placeholder.summarizeComments": "摘要評論…", "prompt.placeholder.summarizeComments": "摘要評論…",
"prompt.placeholder.summarizeComment": "摘要這則評論…", "prompt.placeholder.summarizeComment": "摘要這則評論…",
"prompt.mode.shell": "Shell", "prompt.mode.shell": "Shell",
"prompt.mode.normal": "Prompt",
"prompt.mode.shell.exit": "按 esc 退出", "prompt.mode.shell.exit": "按 esc 退出",
"prompt.example.1": "修復程式碼庫中的一個 TODO", "prompt.example.1": "修復程式碼庫中的一個 TODO",
@ -497,6 +501,9 @@ export const dict = {
"session.messages.jumpToLatest": "跳到最新", "session.messages.jumpToLatest": "跳到最新",
"session.context.addToContext": "將 {{selection}} 新增到上下文", "session.context.addToContext": "將 {{selection}} 新增到上下文",
"session.todo.title": "待辦事項",
"session.todo.collapse": "折疊",
"session.todo.expand": "展開",
"session.new.worktree.main": "主分支", "session.new.worktree.main": "主分支",
"session.new.worktree.mainWithBranch": "主分支 ({{branch}})", "session.new.worktree.mainWithBranch": "主分支 ({{branch}})",

View file

@ -1,4 +1,5 @@
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"

View file

@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)} onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)} onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`} onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
> >
<LocalProvider>{props.children}</LocalProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider> </DataProvider>

View file

@ -51,7 +51,6 @@ 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"
@ -178,7 +177,12 @@ 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 clearHoverProjectSoon = () => queueMicrotask(() => setState("hoverProject", undefined)) const setHoverProject = (value: string | 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(() => {
@ -189,13 +193,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => { createEffect(() => {
if (!layout.sidebar.opened()) return if (!layout.sidebar.opened()) return
aim.reset() setHoverProject(undefined)
setState("hoverProject", undefined)
})
createEffect(() => {
if (state.hoverProject !== undefined) return
aim.reset()
}) })
const autoselecting = createMemo(() => { const autoselecting = createMemo(() => {
@ -226,7 +224,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)
setState("hoverProject", undefined) setHoverProject(undefined)
} }
const navigateWithSidebarReset = (href: string) => { const navigateWithSidebarReset = (href: string) => {
@ -826,14 +824,6 @@ 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}`))
} }
@ -869,15 +859,6 @@ 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
@ -1508,7 +1489,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
setState("hoverProject", undefined) setHoverProject(undefined)
setStore("activeProject", id) setStore("activeProject", id)
} }
@ -1710,7 +1691,7 @@ export default function Layout(props: ParentProps) {
return ( return (
<div <div
classList={{ classList={{
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true, "flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-[12px]": true,
"flex-1 min-w-0": panelProps.mobile, "flex-1 min-w-0": panelProps.mobile,
}} }}
style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
@ -1725,8 +1706,8 @@ export default function Layout(props: ParentProps) {
id={`project:${projectId()}`} id={`project:${projectId()}`}
value={projectName} value={projectName}
onSave={(next) => renameProject(p(), next)} onSave={(next) => renameProject(p(), next)}
class="text-16-medium text-text-strong truncate" class="text-14-medium text-text-strong truncate"
displayClass="text-16-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate"
stopPropagation stopPropagation
/> />
@ -1942,7 +1923,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
setState("hoverProject", undefined) setHoverProject(undefined)
setState("hoverSession", undefined) setState("hoverSession", undefined)
}, 300) }, 300)
}} }}
@ -2042,7 +2023,7 @@ export default function Layout(props: ParentProps) {
<main <main
classList={{ classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true, "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(), "xl:border-l xl:rounded-tl-[12px]": !layout.sidebar.opened(),
}} }}
> >
<Show when={!autoselecting()} fallback={<div class="size-full" />}> <Show when={!autoselecting()} fallback={<div class="size-full" />}>

View file

@ -51,7 +51,7 @@ export const SidebarContent = (props: {
> >
<DragDropSensors /> <DragDropSensors />
<ConstrainDragXAxis /> <ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar"> <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-3 overflow-y-auto no-scrollbar">
<SortableProvider ids={props.projects().map((p) => p.worktree)}> <SortableProvider ids={props.projects().map((p) => p.worktree)}>
<For each={props.projects()}>{(project) => props.renderProject(project)}</For> <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
</SortableProvider> </SortableProvider>
@ -78,7 +78,7 @@ export const SidebarContent = (props: {
<DragOverlay>{props.renderProjectOverlay()}</DragOverlay> <DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
</DragDropProvider> </DragDropProvider>
</div> </div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2"> <div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}> <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton <IconButton
icon="settings-gear" icon="settings-gear"

View file

@ -1,55 +1,30 @@
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { onCleanup, Show, Match, Switch, createMemo, createEffect, on, onMount } 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, produce } from "solid-js/store" import { createStore } from "solid-js/store"
import { SessionContextUsage } from "@/components/session-context-usage"
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 { 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 { 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 { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { SessionHeader, NewSessionView } from "@/components/session"
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, focusTerminalById, getTabReorderIndex } from "@/pages/session/helpers" import { createOpenReviewFile } from "@/pages/session/helpers"
import { createScrollSpy } from "@/pages/session/scroll-spy" import { createScrollSpy } from "@/pages/session/scroll-spy"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/pages/session/review-tab"
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 { SessionPromptDock } from "@/pages/session/session-prompt-dock" import { SessionPromptDock } from "@/pages/session/session-prompt-dock"
@ -57,42 +32,12 @@ 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 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()
@ -100,53 +45,21 @@ export default function Page() {
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
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,
}, },
}) })
createEffect( const blocked = createMemo(() => {
on( const sessionID = params.id
() => permRequest()?.id, if (!sessionID) return false
() => setUi("responding", false), return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0]
{ 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))
@ -189,47 +102,7 @@ export default function Page() {
), ),
) )
if (import.meta.env.DEV) { const isDesktop = createMediaQuery("(min-width: 768px)")
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: 1024px)")
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())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@ -261,17 +134,6 @@ 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()
tabs().setActive(next)
}
createEffect(() => { createEffect(() => {
const active = tabs().active() const active = tabs().active()
if (!active) return if (!active) return
@ -320,206 +182,6 @@ 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[],
@ -552,15 +214,11 @@ export default function Page() {
) )
const [store, setStore] = createStore({ const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined, messageId: undefined as string | undefined,
turnStart: 0, turnStart: 0,
mobileTab: "session" as "session" | "changes", mobileTab: "session" as "session" | "changes",
changes: "session" as "session" | "turn", changes: "session" as "session" | "turn",
newSessionWorktree: "main", newSessionWorktree: "main",
promptHeight: 0,
}) })
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
@ -614,33 +272,6 @@ 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
@ -648,9 +279,9 @@ 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 scroller: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
@ -673,46 +304,10 @@ export default function Page() {
sdk.directory sdk.directory
const id = params.id const id = params.id
if (!id) return if (!id) return
sync.session.sync(id) void sync.session.sync(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,
@ -725,16 +320,12 @@ export default function Page() {
), ),
) )
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect( createEffect(
on( on(
sessionKey, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session") setStore("changes", "session")
setUi("autoCreated", false)
}, },
{ defer: true }, { defer: true },
), ),
@ -751,12 +342,6 @@ export default function Page() {
), ),
) )
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => { const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content const content = file.get(path)?.content?.content
if (!content) return undefined if (!content) return undefined
@ -806,8 +391,8 @@ export default function Page() {
return return
} }
// Don't autofocus chat if terminal panel is open // Don't autofocus chat if desktop terminal panel is open
if (view().terminal.opened()) return if (isDesktop() && view().terminal.opened()) return
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@ -821,53 +406,6 @@ export default function Page() {
} }
} }
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()
@ -905,10 +443,7 @@ export default function Page() {
const focusInput = () => inputRef?.focus() const focusInput = () => inputRef?.focus()
useSessionCommands({ useSessionCommands({
activeMessage,
showAllFiles,
navigateMessageByOffset, navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage, setActiveMessage,
focusInput, focusInput,
}) })
@ -933,7 +468,6 @@ export default function Page() {
onSelect={(option) => option && setStore("changes", option)} onSelect={(option) => option && setStore("changes", option)}
variant="ghost" variant="ghost"
size="large" size="large"
triggerStyle={{ "font-size": "var(--font-size-large)" }}
/> />
) )
@ -1048,11 +582,6 @@ 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
@ -1148,12 +677,6 @@ 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
@ -1421,12 +944,12 @@ export default function Page() {
({ height }) => { ({ height }) => {
const next = Math.ceil(height) const next = Math.ceil(height)
if (next === store.promptHeight) return if (next === dockHeight) return
const el = scroller const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next) dockHeight = next
if (stick && el) { if (stick && el) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@ -1458,62 +981,10 @@ export default function Page() {
consumePendingMessage: layout.pendingMessage.consume, consumePendingMessage: layout.pendingMessage.consume,
}) })
createEffect(() => { onMount(() => {
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)
@ -1524,13 +995,7 @@ export default function Page() {
return ( return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col"> <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<SessionHeader /> <SessionHeader />
<div <div class="flex-1 min-h-0 flex flex-col md:flex-row">
class="flex-1 min-h-0 flex"
classList={{
"flex-col": !isDesktop(),
"flex-row": isDesktop(),
}}
>
<SessionMobileTabs <SessionMobileTabs
open={!isDesktop() && !!params.id} open={!isDesktop() && !!params.id}
mobileTab={store.mobileTab} mobileTab={store.mobileTab}
@ -1538,19 +1003,17 @@ 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 */}
<div <div
classList={{ classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true, "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 pt-2 md:pt-3": true, "flex-1": true,
"md:flex-none": desktopSidePanelOpen(), "md:flex-none": desktopSidePanelOpen(),
}} }}
style={{ style={{
width: sessionPanelWidth(), width: sessionPanelWidth(),
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}} }}
> >
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
@ -1562,7 +1025,7 @@ export default function Page() {
mobileFallback={reviewContent({ mobileFallback={reviewContent({
diffStyle: "unified", diffStyle: "unified",
classes: { classes: {
root: "pb-[calc(var(--prompt-height,8rem)+32px)]", root: "pb-8",
header: "px-4", header: "px-4",
container: "px-4", container: "px-4",
}, },
@ -1579,27 +1042,7 @@ 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)
@ -1621,14 +1064,7 @@ 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}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/> />
</Show> </Show>
</Match> </Match>
@ -1656,14 +1092,6 @@ export default function Page() {
<SessionPromptDock <SessionPromptDock
centered={centered()} centered={centered()}
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
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
}} }}
@ -1673,7 +1101,9 @@ export default function Page() {
comments.clear() comments.clear()
resumeScroll() resumeScroll()
}} }}
setPromptDockRef={(el) => (promptDock = el)} setPromptDockRef={(el) => {
promptDock = el
}}
/> />
<Show when={desktopReviewOpen()}> <Show when={desktopReviewOpen()}>
@ -1687,64 +1117,10 @@ export default function Page() {
</Show> </Show>
</div> </div>
<SessionSidePanel <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
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={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>
) )
} }

View file

@ -1,6 +1,8 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { 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"
@ -8,9 +10,11 @@ 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 { useFile, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, 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)
@ -19,34 +23,29 @@ const formatCommentLabel = (range: SelectedLineRange) => {
return `lines ${start}-${end}` return `lines ${start}-${end}`
} }
export function FileTabContent(props: { export function FileTabContent(props: { tab: string }) {
tab: string const params = useParams()
activeTab: () => string const layout = useLayout()
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]> const file = useFile()
view: () => ReturnType<ReturnType<typeof useLayout>["view"]> const comments = useComments()
handoffFiles: () => Record<string, SelectedLineRange | null> | undefined const language = useLanguage()
file: ReturnType<typeof useFile> const prompt = usePrompt()
comments: ReturnType<typeof useComments> const codeComponent = useCodeComponent()
language: ReturnType<typeof useLanguage>
codeComponent: NonNullable<ValidComponent> const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
addCommentToContext: (input: { const tabs = createMemo(() => layout.tabs(sessionKey))
file: string const view = createMemo(() => layout.view(sessionKey))
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(() => props.file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
const state = createMemo(() => { const state = createMemo(() => {
const p = path() const p = path()
if (!p) return if (!p) return
return props.file.get(p) return 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()))
@ -82,7 +81,7 @@ export function FileTabContent(props: {
svgToast.shown = true svgToast.shown = true
showToast({ showToast({
variant: "error", variant: "error",
title: props.language.t("toast.file.loadFailed.title"), title: language.t("toast.file.loadFailed.title"),
}) })
}) })
const svgPreviewUrl = createMemo(() => { const svgPreviewUrl = createMemo(() => {
@ -100,16 +99,57 @@ export function FileTabContent(props: {
const selectedLines = createMemo(() => { const selectedLines = createMemo(() => {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (props.file.ready()) return props.file.selectedLines(p) ?? null if (file.ready()) return file.selectedLines(p) ?? null
return props.handoffFiles()?.[p] ?? null return getSessionHandoff(sessionKey())?.files[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 props.comments.list(p) return comments.list(p)
}) })
const commentLayout = createMemo(() => { const commentLayout = createMemo(() => {
@ -128,6 +168,13 @@ export function FileTabContent(props: {
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
@ -221,26 +268,19 @@ export function FileTabContent(props: {
}) })
createEffect(() => { createEffect(() => {
const range = note.commenting const focus = comments.focus()
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 (props.activeTab() !== props.tab) return if (tabs().active() !== 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)
setNote("commenting", null) setCommenting(null)
props.file.setSelectedLines(p, target.selection) file.setSelectedLines(p, target.selection)
requestAnimationFrame(() => props.comments.clearFocus()) requestAnimationFrame(() => comments.clearFocus())
}) })
const getCodeScroll = () => { const getCodeScroll = () => {
@ -269,7 +309,7 @@ export function FileTabContent(props: {
pending = undefined pending = undefined
if (!out) return if (!out) return
props.view().setScroll(props.tab, out) view().setScroll(props.tab, out)
}) })
} }
@ -305,7 +345,7 @@ export function FileTabContent(props: {
const el = scroll const el = scroll
if (!el) return if (!el) return
const s = props.view()?.scroll(props.tab) const s = view().scroll(props.tab)
if (!s) return if (!s) return
syncCodeScroll() syncCodeScroll()
@ -343,7 +383,7 @@ export function FileTabContent(props: {
createEffect( createEffect(
on( on(
() => props.file.ready(), () => file.ready(),
(ready) => { (ready) => {
if (!ready) return if (!ready) return
requestAnimationFrame(restoreScroll) requestAnimationFrame(restoreScroll)
@ -354,7 +394,7 @@ export function FileTabContent(props: {
createEffect( createEffect(
on( on(
() => props.tabs().active() === props.tab, () => tabs().active() === props.tab,
(active) => { (active) => {
if (!active) return if (!active) return
if (!state()?.loaded) return if (!state()?.loaded) return
@ -381,7 +421,7 @@ export function FileTabContent(props: {
class={`relative overflow-hidden ${wrapperClass}`} class={`relative overflow-hidden ${wrapperClass}`}
> >
<Dynamic <Dynamic
component={props.codeComponent} component={codeComponent}
file={{ file={{
name: path() ?? "", name: path() ?? "",
contents: source, contents: source,
@ -397,17 +437,17 @@ export function FileTabContent(props: {
onLineSelected={(range: SelectedLineRange | null) => { onLineSelected={(range: SelectedLineRange | null) => {
const p = path() const p = path()
if (!p) return if (!p) return
props.file.setSelectedLines(p, range) file.setSelectedLines(p, range)
if (!range) setNote("commenting", null) if (!range) setCommenting(null)
}} }}
onLineSelectionEnd={(range: SelectedLineRange | null) => { onLineSelectionEnd={(range: SelectedLineRange | null) => {
if (!range) { if (!range) {
setNote("commenting", null) setCommenting(null)
return return
} }
setNote("openedComment", null) setNote("openedComment", null)
setNote("commenting", range) setCommenting(range)
}} }}
overflow="scroll" overflow="scroll"
class="select-text" class="select-text"
@ -423,14 +463,14 @@ export function FileTabContent(props: {
onMouseEnter={() => { onMouseEnter={() => {
const p = path() const p = path()
if (!p) return if (!p) return
props.file.setSelectedLines(p, comment.selection) file.setSelectedLines(p, comment.selection)
}} }}
onClick={() => { onClick={() => {
const p = path() const p = path()
if (!p) return if (!p) return
setNote("commenting", null) setCommenting(null)
setNote("openedComment", (current) => (current === comment.id ? null : comment.id)) setNote("openedComment", (current) => (current === comment.id ? null : comment.id))
props.file.setSelectedLines(p, comment.selection) file.setSelectedLines(p, comment.selection)
}} }}
/> />
)} )}
@ -443,17 +483,12 @@ export function FileTabContent(props: {
value={note.draft} value={note.draft}
selection={formatCommentLabel(range())} selection={formatCommentLabel(range())}
onInput={(value) => setNote("draft", value)} onInput={(value) => setNote("draft", value)}
onCancel={() => setNote("commenting", null)} onCancel={() => setCommenting(null)}
onSubmit={(value) => { onSubmit={(value) => {
const p = path() const p = path()
if (!p) return if (!p) return
props.addCommentToContext({ addCommentToContext({ file: p, selection: range(), comment: value, origin: "file" })
file: p, setCommenting(null)
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
@ -462,7 +497,7 @@ export function FileTabContent(props: {
setTimeout(() => { setTimeout(() => {
if (!document.activeElement || !current.contains(document.activeElement)) { if (!document.activeElement || !current.contains(document.activeElement)) {
setNote("commenting", null) setCommenting(null)
} }
}, 0) }, 0)
}} }}
@ -509,13 +544,13 @@ export function FileTabContent(props: {
<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">{props.language.t("session.files.binaryContent")}</div> <div class="text-14-regular text-text-weak">{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">{props.language.t("common.loading")}...</div> <div class="px-6 py-4 text-text-weak">{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>

View file

@ -0,0 +1,36 @@
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)

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers" import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => { test("opens and loads selected review file", () => {
@ -20,6 +20,37 @@ 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>`

View file

@ -35,6 +35,27 @@ 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)

View file

@ -1,13 +1,21 @@
import { For, onCleanup, onMount, Show, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, 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 { Tooltip } from "@opencode-ai/ui/tooltip"
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 { 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
@ -53,29 +61,7 @@ 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
@ -86,13 +72,234 @@ 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
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) { }) {
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}
@ -100,7 +307,7 @@ export function MessageTimeline(props: {
> >
<div class="relative w-full h-full min-w-0"> <div class="relative w-full h-full min-w-0">
<div <div
class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out" class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{ classList={{
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom, "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
"opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom, "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
@ -159,96 +366,110 @@ 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={{ "--session-title-height": props.showHeader ? "40px" : "0px" }} style={{ "--session-title-height": showHeader() ? "40px" : "0px" }}
> >
<Show when={props.showHeader}> <Show when={showHeader()}>
<div <div
classList={{ classList={{
"sticky top-0 z-30 bg-background-stronger": true, "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true, "w-full": true,
"px-4 md:px-6": true, "pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<div class="h-10 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"> <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={props.parentID}> <Show when={parentID()}>
<IconButton <IconButton
tabIndex={-1} tabIndex={-1}
icon="arrow-left" icon="arrow-left"
variant="ghost" variant="ghost"
onClick={props.onNavigateParent} onClick={navigateParent}
aria-label={props.t("common.goBack")} aria-label={language.t("common.goBack")}
/> />
</Show> </Show>
<Show when={props.title || props.titleState.editing}> <Show when={titleValue() || title.editing}>
<Show <Show
when={props.titleState.editing} when={title.editing}
fallback={ fallback={
<h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}> <h1
{props.title} class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1> </h1>
} }
> >
<InlineInput <InlineInput
ref={props.titleRef} ref={(el) => {
value={props.titleState.draft} titleRef = el
disabled={props.titleState.saving} }}
class="text-16-medium text-text-strong grow-1 min-w-0" value={title.draft}
onInput={(event) => props.onTitleDraft(event.currentTarget.value)} disabled={title.saving}
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)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
if (event.key === "Enter") { if (event.key === "Enter") {
event.preventDefault() event.preventDefault()
void props.saveTitleEditor() void saveTitleEditor()
return return
} }
if (event.key === "Escape") { if (event.key === "Escape") {
event.preventDefault() event.preventDefault()
props.closeTitleEditor() closeTitleEditor()
} }
}} }}
onBlur={props.closeTitleEditor} onBlur={closeTitleEditor}
/> />
</Show> </Show>
</Show> </Show>
</div> </div>
<Show when={props.sessionID}> <Show when={sessionID()}>
{(id) => ( {(id) => (
<div class="shrink-0 flex items-center"> <div class="shrink-0 flex items-center gap-3">
<DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}> <SessionContextUsage placement="bottom" />
<Tooltip value={props.t("common.moreOptions")} placement="top"> <DropdownMenu
<DropdownMenu.Trigger gutter={4}
as={IconButton} placement="bottom-end"
icon="dot-grid" open={title.menuOpen}
variant="ghost" onOpenChange={(open) => setTitle("menuOpen", open)}
class="size-6 rounded-md data-[expanded]:bg-surface-base-active" >
aria-label={props.t("common.moreOptions")} <DropdownMenu.Trigger
/> as={IconButton}
</Tooltip> icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content <DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => { onCloseAutoFocus={(event) => {
if (!props.titleState.pendingRename) return if (!title.pendingRename) return
event.preventDefault() event.preventDefault()
props.onTitlePendingRename(false) setTitle("pendingRename", false)
props.openTitleEditor() openTitleEditor()
}} }}
> >
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={() => {
props.onTitlePendingRename(true) setTitle("pendingRename", true)
props.onTitleMenuOpen(false) setTitle("menuOpen", false)
}} }}
> >
<DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}> <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}> <DropdownMenu.Item
<DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel> onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@ -263,7 +484,7 @@ export function MessageTimeline(props: {
<div <div
ref={props.setContentRef} ref={props.setContentRef}
role="log" role="log"
class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{ classList={{
"w-full": true, "w-full": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
@ -274,7 +495,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}>
{props.t("session.messages.renderEarlier")} {language.t("session.messages.renderEarlier")}
</Button> </Button>
</div> </div>
</Show> </Show>
@ -288,45 +509,37 @@ export function MessageTimeline(props: {
onClick={props.onLoadEarlier} onClick={props.onLoadEarlier}
> >
{props.historyLoading {props.historyLoading
? props.t("session.messages.loadingEarlier") ? language.t("session.messages.loadingEarlier")
: props.t("session.messages.loadEarlier")} : language.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) { <div
onMount(() => props.onFirstTurnMount?.()) id={props.anchor(message.id)}
} data-message-id={message.id}
ref={(el) => {
return ( props.onRegisterMessage(el, message.id)
<div onCleanup(() => props.onUnregisterMessage(message.id))
id={props.anchor(message.id)} }}
data-message-id={message.id} classList={{
ref={(el) => { "min-w-0 w-full max-w-full": true,
props.onRegisterMessage(el, message.id) "md:max-w-200 2xl:max-w-[1000px]": props.centered,
onCleanup(() => props.onUnregisterMessage(message.id)) }}
>
<SessionTurn
sessionID={sessionID() ?? ""}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}} }}
classList={{ />
"min-w-0 w-full max-w-full": true, </div>
"md:max-w-200 2xl:max-w-[1000px]": props.centered, )}
}}
>
<SessionTurn
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",
container: "w-full px-4 md:px-6",
}}
/>
</div>
)
}}
</For> </For>
</div> </div>
</div> </div>

View file

@ -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-6", header: props.classes?.header ?? "px-3",
container: props.classes?.container ?? "px-6", container: props.classes?.container ?? "px-3",
}} }}
diffs={props.diffs()} diffs={props.diffs()}
diffStyle={props.diffStyle} diffStyle={props.diffStyle}

View file

@ -1,5 +1,6 @@
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
@ -8,8 +9,9 @@ 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">
@ -20,7 +22,7 @@ export function SessionMobileTabs(props: {
classes={{ button: "w-full" }} classes={{ button: "w-full" }}
onClick={props.onSession} onClick={props.onSession}
> >
{props.t("session.tab.session")} {language.t("session.tab.session")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger <Tabs.Trigger
value="changes" value="changes"
@ -29,8 +31,8 @@ export function SessionMobileTabs(props: {
onClick={props.onChanges} onClick={props.onChanges}
> >
{props.hasReview {props.hasReview
? props.t("session.review.filesChanged", { count: props.reviewCount }) ? language.t("session.review.filesChanged", { count: props.reviewCount })
: props.t("session.review.change.other")} : language.t("session.review.change.other")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>

View file

@ -1,31 +1,180 @@
import { For, Show } from "solid-js" import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool" import { DockPrompt } from "@opencode-ai/ui/dock-prompt"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock" import { QuestionDock } from "@/components/question-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers" import { SessionTodoDock } from "@/components/session-todo-dock"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
export function SessionPromptDock(props: { export function SessionPromptDock(props: {
centered: boolean centered: boolean
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
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 inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string newSessionWorktree: string
onNewSessionWorktreeReset: () => void onNewSessionWorktreeReset: () => void
onSubmit: () => void onSubmit: () => void
setPromptDockRef: (el: HTMLDivElement) => void setPromptDockRef: (el: HTMLDivElement) => void
}) { }) {
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const prompt = usePrompt()
const language = useLanguage()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt)
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
const questionRequest = createMemo((): QuestionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.question[sessionID]?.[0]
})
const permissionRequest = createMemo((): PermissionRequest | undefined => {
const sessionID = params.id
if (!sessionID) return
return sync.data.permission[sessionID]?.[0]
})
const blocked = createMemo(() => !!permissionRequest() || !!questionRequest())
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() })
})
const [responding, setResponding] = createSignal<string | undefined>()
const permissionResponding = () => {
const perm = permissionRequest()
if (!perm) return false
return responding() === perm.id
}
const decide = (response: "once" | "always" | "reject") => {
const perm = permissionRequest()
if (!perm) return
if (responding() === perm.id) return
setResponding(perm.id)
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(() => {
setResponding((id) => (id === perm.id ? undefined : id))
})
}
const done = createMemo(
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
)
const [dock, setDock] = createSignal(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(
() => [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 ( return (
<div <div
ref={props.setPromptDockRef} ref={props.setPromptDockRef}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" 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 <div
classList={{ classList={{
@ -33,101 +182,134 @@ export function SessionPromptDock(props: {
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<Show when={props.questionRequest()} keyed> <Show when={questionRequest()} keyed>
{(req) => { {(req) => {
const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
return ( return (
<div data-component="tool-part-wrapper" data-question="true" class="mb-3"> <div>
<BasicTool
icon="bubble-5"
locked
defaultOpen
trigger={{
title: props.t("ui.tool.questions"),
subtitle,
}}
/>
<QuestionDock request={req} /> <QuestionDock request={req} />
</div> </div>
) )
}} }}
</Show> </Show>
<Show when={props.permissionRequest()} keyed> <Show when={permissionRequest()} keyed>
{(perm) => ( {(perm) => {
<div data-component="tool-part-wrapper" data-permission="true" class="mb-3"> const toolDescription = () => {
<BasicTool const key = `settings.permissions.tool.${perm.permission}.description`
icon="checklist" const value = language.t(key as Parameters<typeof language.t>[0])
locked if (value === key) return ""
defaultOpen return value
trigger={{ }
title: props.t("notification.permission.title"),
subtitle: return (
perm.permission === "doom_loop" <div>
? props.t("settings.permissions.tool.doom_loop.title") <DockPrompt
: perm.permission, kind="permission"
}} header={
> <div data-slot="permission-row" data-variant="header">
<Show when={perm.patterns.length > 0}> <span data-slot="permission-icon">
<div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar"> <Icon name="warning" size="normal" />
<For each={perm.patterns}> </span>
{(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div>
</For> </div>
</div> }
</Show> footer={
<Show when={perm.permission === "doom_loop"}> <>
<div class="text-12-regular text-text-weak pb-2 px-3"> <div />
{props.t("settings.permissions.tool.doom_loop.description")} <div data-slot="permission-footer-actions">
</div> <Button
</Show> variant="ghost"
</BasicTool> size="normal"
<div data-component="permission-prompt"> onClick={() => decide("reject")}
<div data-slot="permission-actions"> disabled={permissionResponding()}
<Button >
variant="ghost" {language.t("ui.permission.deny")}
size="small" </Button>
onClick={() => props.onDecide("reject")} <Button
disabled={props.responding} variant="secondary"
> size="normal"
{props.t("ui.permission.deny")} onClick={() => decide("always")}
</Button> disabled={permissionResponding()}
<Button >
variant="secondary" {language.t("ui.permission.allowAlways")}
size="small" </Button>
onClick={() => props.onDecide("always")} <Button
disabled={props.responding} variant="primary"
> size="normal"
{props.t("ui.permission.allowAlways")} onClick={() => decide("once")}
</Button> disabled={permissionResponding()}
<Button >
variant="primary" {language.t("ui.permission.allowOnce")}
size="small" </Button>
onClick={() => props.onDecide("once")} </div>
disabled={props.responding} </>
> }
{props.t("ui.permission.allowOnce")} >
</Button> <Show when={toolDescription()}>
</div> <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> </div>
</div> )
)} }}
</Show> </Show>
<Show when={!props.blocked}> <Show when={!blocked()}>
<Show <Show
when={props.promptReady} when={prompt.ready()}
fallback={ 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"> <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")} {handoffPrompt() || language.t("prompt.loading")}
</div> </div>
} }
> >
<PromptInput <Show when={dock()}>
ref={props.inputRef} <div
newSessionWorktree={props.newSessionWorktree} classList={{
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true,
onSubmit={props.onSubmit} "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={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": dock() && !closing(),
"mt-0": !dock() || closing(),
}}
>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
onSubmit={props.onSubmit}
/>
</div>
</Show> </Show>
</Show> </Show>
</div> </div>

View file

@ -1,156 +1,268 @@
import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js" import { For, Match, Show, Switch, createEffect, createMemo, onCleanup, type JSX } 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 { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
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 type { Message, UserMessage } from "@opencode-ai/sdk/v2/client" import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
import { FileTabContent } from "@/pages/session/file-tabs"
type SessionSidePanelViewModel = { import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
messages: () => Message[] import { StickyAddButton } from "@/pages/session/review-tab"
visibleUserMessages: () => UserMessage[] import { setSessionHandoff } from "@/pages/session/handoff"
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 openedTabs = createMemo(() => props.openedTabs()) const params = useParams()
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={props.open}> <Show when={open()}>
<aside <aside
id="review-panel" id="review-panel"
aria-label={props.language.t("session.panel.reviewAndFiles")} aria-label={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": props.reviewOpen, "flex-1": reviewOpen(),
"shrink-0": !props.reviewOpen, "shrink-0": !reviewOpen(),
}} }}
style={{ width: props.reviewOpen ? undefined : `${props.layout.fileTree.width()}px` }} style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
> >
<Show when={props.reviewOpen}> <Show when={reviewOpen()}>
<div class="flex-1 min-w-0 h-full"> <div class="flex-1 min-w-0 h-full">
<Show <Show
when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"} when={layout.fileTree.opened() && fileTreeTab() === "changes"}
fallback={ fallback={
<DragDropProvider <DragDropProvider
onDragStart={props.onDragStart} onDragStart={handleDragStart}
onDragEnd={props.onDragEnd} onDragEnd={handleDragEnd}
onDragOver={props.onDragOver} onDragOver={handleDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
<ConstrainDragYAxis /> <ConstrainDragYAxis />
<Tabs value={props.activeTab()} onChange={props.openTab}> <Tabs value={activeTab()} onChange={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: props.contextOpen }) const stop = createFileTabListSync({ el, contextOpen })
onCleanup(stop) onCleanup(stop)
}} }}
> >
<Show when={props.reviewTab}> <Show when={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>{props.language.t("session.tab.review")}</div> <div>{language.t("session.tab.review")}</div>
<Show when={props.hasReview}> <Show when={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">
{props.reviewCount} {reviewCount()}
</div> </div>
</Show> </Show>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<Show when={props.contextOpen()}> <Show when={contextOpen()}>
<Tabs.Trigger <Tabs.Trigger
value="context" value="context"
closeButton={ closeButton={
<Tooltip value={props.language.t("common.closeTab")} placement="bottom"> <Tooltip value={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={() => props.tabs().close("context")} onClick={() => tabs().close("context")}
aria-label={props.language.t("common.closeTab")} aria-label={language.t("common.closeTab")}
/> />
</Tooltip> </Tooltip>
} }
hideCloseButton hideCloseButton
onMiddleClick={() => props.tabs().close("context")} onMiddleClick={() => tabs().close("context")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" /> <SessionContextUsage variant="indicator" />
<div>{props.language.t("session.tab.context")}</div> <div>{language.t("session.tab.context")}</div>
</div> </div>
</Tabs.Trigger> </Tabs.Trigger>
</Show> </Show>
<SortableProvider ids={openedTabs()}> <SortableProvider ids={openedTabs()}>
<For each={openedTabs()}> <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
{(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
</For>
</SortableProvider> </SortableProvider>
<StickyAddButton> <StickyAddButton>
<TooltipKeybind <TooltipKeybind
title={props.language.t("command.file.open")} title={language.t("command.file.open")}
keybind={props.command.keybind("file.open")} keybind={command.keybind("file.open")}
class="flex items-center" class="flex items-center"
> >
<IconButton <IconButton
@ -158,72 +270,52 @@ export function SessionSidePanel(props: {
variant="ghost" variant="ghost"
iconSize="large" iconSize="large"
onClick={() => onClick={() =>
props.dialog.show(() => ( dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
<DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />
))
} }
aria-label={props.language.t("command.file.open")} aria-label={language.t("command.file.open")}
/> />
</TooltipKeybind> </TooltipKeybind>
</StickyAddButton> </StickyAddButton>
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={props.reviewTab}> <Show when={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={props.activeTab() === "review"}>{props.reviewPanel()}</Show> <Show when={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={props.activeTab() === "empty"}> <Show when={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">
{props.language.t("session.files.selectToOpen")} {language.t("session.files.selectToOpen")}
</div> </div>
</div> </div>
</div> </div>
</Show> </Show>
</Tabs.Content> </Tabs.Content>
<Show when={props.contextOpen()}> <Show when={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={props.activeTab() === "context"}> <Show when={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={props.activeFileTab()} keyed> <Show when={activeFileTab()} keyed>
{(tab) => ( {(tab) => <FileTabContent 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={props.activeDraggable()}> <Show when={store.activeDraggable} keyed>
{(tab) => { {(tab) => {
const path = createMemo(() => props.file.pathFromTab(tab())) const path = createMemo(() => 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>
@ -240,50 +332,44 @@ export function SessionSidePanel(props: {
</div> </div>
</Show> </Show>
<Show when={props.layout.fileTree.opened()}> <Show when={layout.fileTree.opened()}>
<div <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
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": props.reviewOpen }} classList={{ "border-l border-border-weak-base": reviewOpen() }}
> >
<Tabs <Tabs
variant="pill" variant="pill"
value={props.fileTreeTab()} value={fileTreeTab()}
onChange={props.setFileTreeTabValue} onChange={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" }}>
{props.reviewCount}{" "} {reviewCount()}{" "}
{props.language.t( {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
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" }}>
{props.language.t("session.files.all")} {language.t("session.files.all")}
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="changes" class="bg-background-base px-3 py-0"> <Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
<Switch> <Switch>
<Match when={props.hasReview}> <Match when={hasReview()}>
<Show <Show
when={props.diffsReady} when={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">
{props.language.t("common.loading")} {language.t("common.loading")}
{props.language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
} }
> >
<FileTree <FileTree
path="" path=""
allowed={props.diffFiles} allowed={diffFiles()}
kinds={props.kinds} kinds={kinds()}
draggable={false} draggable={false}
active={props.activeDiff} active={props.activeDiff}
onFileClick={(node) => props.focusReviewDiff(node.path)} onFileClick={(node) => props.focusReviewDiff(node.path)}
@ -292,17 +378,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">
{props.language.t("session.review.noChanges")} {language.t("session.review.noChanges")}
</div> </div>
</Match> </Match>
</Switch> </Switch>
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="all" class="bg-background-base px-3 py-0"> <Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
<FileTree <FileTree
path="" path=""
modified={props.diffFiles} modified={diffFiles()}
kinds={props.kinds} kinds={kinds()}
onFileClick={(node) => props.openTab(props.file.tab(node.path))} onFileClick={(node) => openTab(file.tab(node.path))}
/> />
</Tabs.Content> </Tabs.Content>
</Tabs> </Tabs>
@ -310,12 +396,12 @@ export function SessionSidePanel(props: {
<ResizeHandle <ResizeHandle
direction="horizontal" direction="horizontal"
edge="start" edge="start"
size={props.layout.fileTree.width()} size={layout.fileTree.width()}
min={200} min={200}
max={480} max={480}
collapseThreshold={160} collapseThreshold={160}
onResize={props.layout.fileTree.resize} onResize={layout.fileTree.resize}
onCollapse={props.layout.fileTree.close} onCollapse={layout.fileTree.close}
/> />
</div> </div>
</Show> </Show>

View file

@ -1,61 +1,161 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createEffect, createMemo, on } 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 } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } 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 { useTerminal } from "@/context/terminal"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
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(props: { export function TerminalPanel() {
open: boolean const params = useParams()
height: number const layout = useLayout()
resize: (value: number) => void const terminal = useTerminal()
close: () => void const language = useLanguage()
terminal: ReturnType<typeof useTerminal> const command = useCommand()
language: ReturnType<typeof useLanguage>
command: ReturnType<typeof useCommand> const isDesktop = createMediaQuery("(min-width: 768px)")
handoff: () => string[] const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
activeTerminalDraggable: () => string | undefined const view = createMemo(() => layout.view(sessionKey))
handleTerminalDragStart: (event: unknown) => void
handleTerminalDragOver: (event: DragEvent) => void const opened = createMemo(() => view().terminal.opened())
handleTerminalDragEnd: () => void const open = createMemo(() => isDesktop() && opened())
onCloseTab: () => void const height = createMemo(() => layout.terminal.height())
}) { 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={props.open}> <Show when={open()}>
<div <div
id="terminal-panel" id="terminal-panel"
role="region" role="region"
aria-label={props.language.t("terminal.title")} aria-label={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: `${props.height}px` }} style={{ height: `${height()}px` }}
> >
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
size={props.height} size={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={props.resize} onResize={layout.terminal.resize}
onCollapse={props.close} onCollapse={close}
/> />
<Show <Show
when={props.terminal.ready()} when={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={props.handoff()}> <For each={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}
@ -64,20 +164,18 @@ export function TerminalPanel(props: {
</For> </For>
<div class="flex-1" /> <div class="flex-1" />
<div class="text-text-weak pr-2"> <div class="text-text-weak pr-2">
{props.language.t("common.loading")} {language.t("common.loading")}
{props.language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak"> <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
{props.language.t("terminal.loading")}
</div>
</div> </div>
} }
> >
<DragDropProvider <DragDropProvider
onDragStart={props.handleTerminalDragStart} onDragStart={handleTerminalDragStart}
onDragEnd={props.handleTerminalDragEnd} onDragEnd={handleTerminalDragEnd}
onDragOver={props.handleTerminalDragOver} onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter} collisionDetector={closestCenter}
> >
<DragDropSensors /> <DragDropSensors />
@ -85,36 +183,26 @@ export function TerminalPanel(props: {
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<Tabs <Tabs
variant="alt" variant="alt"
value={props.terminal.active()} value={terminal.active()}
onChange={(id) => props.terminal.open(id)} onChange={(id) => 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()}> <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
{(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={props.language.t("command.terminal.new")} title={language.t("command.terminal.new")}
keybind={props.command.keybind("terminal.new")} keybind={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={props.terminal.new} onClick={terminal.new}
aria-label={props.language.t("command.terminal.new")} aria-label={language.t("command.terminal.new")}
/> />
</TooltipKeybind> </TooltipKeybind>
</div> </div>
@ -127,15 +215,11 @@ export function TerminalPanel(props: {
id={`terminal-wrapper-${pty.id}`} id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0" class="absolute inset-0"
style={{ style={{
display: props.terminal.active() === pty.id ? "block" : "none", display: terminal.active() === pty.id ? "block" : "none",
}} }}
> >
<Show when={pty.id} keyed> <Show when={pty.id} keyed>
<Terminal <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
pty={pty}
onCleanup={props.terminal.update}
onConnectError={() => props.terminal.clone(pty.id)}
/>
</Show> </Show>
</div> </div>
)} )}
@ -143,25 +227,20 @@ export function TerminalPanel(props: {
</div> </div>
</div> </div>
<DragOverlay> <DragOverlay>
<Show when={props.activeTerminalDraggable()}> <Show when={store.activeDraggable}>
{(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, </div>
vars?: Record<string, string | number | boolean>, )}
) => string, </Show>
})} )}
</div>
)}
</Show>
)
}}
</Show> </Show>
</DragOverlay> </DragOverlay>
</DragDropProvider> </DragDropProvider>

View file

@ -22,10 +22,7 @@ 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 = {
activeMessage: () => UserMessage | undefined
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void
focusInput: () => void focusInput: () => void
} }
@ -37,7 +34,7 @@ const withCategory = (category: string) => {
}) })
} }
export const useSessionCommands = (args: SessionCommandContext) => { export const useSessionCommands = (actions: SessionCommandContext) => {
const command = useCommand() const command = useCommand()
const dialog = useDialog() const dialog = useDialog()
const file = useFile() const file = useFile()
@ -56,6 +53,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const tabs = createMemo(() => layout.tabs(sessionKey)) const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
@ -66,6 +64,11 @@ export const useSessionCommands = (args: SessionCommandContext) => {
return userMessages().filter((m) => m.id < revert) 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 selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content const content = file.get(path)?.content?.content
if (!content) return undefined if (!content) return undefined
@ -81,6 +84,10 @@ export const useSessionCommands = (args: SessionCommandContext) => {
prompt.context.add({ type: "file", path, selection, preview }) 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 sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file")) const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context")) const contextCommand = withCategory(language.t("command.category.context"))
@ -108,7 +115,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
description: language.t("palette.search.placeholder"), description: language.t("palette.search.placeholder"),
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />), onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}), }),
fileCommand({ fileCommand({
id: "tab.close", id: "tab.close",
@ -178,7 +185,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
id: "input.focus", id: "input.focus",
title: language.t("command.input.focus"), title: language.t("command.input.focus"),
keybind: "ctrl+l", keybind: "ctrl+l",
onSelect: () => args.focusInput(), onSelect: () => focusInput(),
}), }),
terminalCommand({ terminalCommand({
id: "terminal.new", id: "terminal.new",
@ -190,19 +197,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
view().terminal.open() view().terminal.open()
}, },
}), }),
viewCommand({
id: "steps.toggle",
title: language.t("command.steps.toggle"),
description: language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = args.activeMessage()
if (!msg) return
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
},
}),
]) ])
const messageCommands = createMemo(() => [ const messageCommands = createMemo(() => [
@ -212,7 +206,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
description: language.t("command.message.previous.description"), description: language.t("command.message.previous.description"),
keybind: "mod+arrowup", keybind: "mod+arrowup",
disabled: !params.id, disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(-1), onSelect: () => navigateMessageByOffset(-1),
}), }),
sessionCommand({ sessionCommand({
id: "message.next", id: "message.next",
@ -220,7 +214,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
description: language.t("command.message.next.description"), description: language.t("command.message.next.description"),
keybind: "mod+arrowdown", keybind: "mod+arrowdown",
disabled: !params.id, disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(1), onSelect: () => navigateMessageByOffset(1),
}), }),
]) ])
@ -315,7 +309,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
prompt.set(restored) prompt.set(restored)
} }
const priorMessage = findLast(userMessages(), (x) => x.id < message.id) const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
args.setActiveMessage(priorMessage) setActiveMessage(priorMessage)
}, },
}), }),
sessionCommand({ sessionCommand({
@ -334,12 +328,12 @@ export const useSessionCommands = (args: SessionCommandContext) => {
await sdk.client.session.unrevert({ sessionID }) await sdk.client.session.unrevert({ sessionID })
prompt.reset() prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
args.setActiveMessage(lastMsg) setActiveMessage(lastMsg)
return return
} }
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
args.setActiveMessage(priorMsg) setActiveMessage(priorMsg)
}, },
}), }),
sessionCommand({ sessionCommand({
@ -495,6 +489,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionCommands(), permissionCommands(),
sessionActionCommands(), sessionActionCommands(),
shareCommands(), shareCommands(),
].flatMap((section) => section), ].flatMap((x) => x),
) )
} }

View file

@ -1,6 +1,8 @@
/* 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 {

View file

@ -1,135 +0,0 @@
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)
}

View file

@ -1,6 +1,11 @@
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
@ -15,7 +20,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("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: true, version: "1.2.3" }) expect(result).toEqual({ healthy: true, version: "1.2.3" })
}) })
@ -25,7 +30,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("http://localhost:4096", fetch) const result = await checkServerHealth(server, fetch)
expect(result).toEqual({ healthy: false }) expect(result).toEqual({ healthy: false })
}) })
@ -51,7 +56,9 @@ describe("checkServerHealth", () => {
) )
})) as unknown as typeof globalThis.fetch })) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { const result = await checkServerHealth(server, fetch, {
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")
}) })
@ -71,7 +78,9 @@ describe("checkServerHealth", () => {
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const abort = new AbortController() const abort = new AbortController()
await checkServerHealth("http://localhost:4096", fetch, { signal: abort.signal }) await checkServerHealth(server, fetch, {
signal: abort.signal,
})
expect(signal).toBe(abort.signal) expect(signal).toBe(abort.signal)
}) })
@ -87,7 +96,7 @@ describe("checkServerHealth", () => {
}) })
}) as unknown as typeof globalThis.fetch }) as unknown as typeof globalThis.fetch
const result = await checkServerHealth("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })
@ -103,7 +112,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("http://localhost:4096", fetch, { const result = await checkServerHealth(server, fetch, {
retryCount: 2, retryCount: 2,
retryDelayMs: 1, retryDelayMs: 1,
}) })

View file

@ -1,4 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import type { ServerConnection } from "@/context/server"
import { createSdkForServer } from "./server"
export type ServerHealth = { healthy: boolean; version?: string } export type ServerHealth = { healthy: boolean; version?: string }
@ -17,7 +18,10 @@ 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 { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } return {
signal: timeout.call(AbortSignal, timeoutMs),
clear: undefined as (() => void) | undefined,
}
} catch {} } catch {}
} }
const controller = new AbortController() const controller = new AbortController()
@ -52,7 +56,7 @@ function retryable(error: unknown, signal?: AbortSignal) {
} }
export async function checkServerHealth( export async function checkServerHealth(
url: string, server: ServerConnection.HttpBase,
fetch: typeof globalThis.fetch, fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions, opts?: CheckServerHealthOptions,
): Promise<ServerHealth> { ): Promise<ServerHealth> {
@ -67,8 +71,8 @@ export async function checkServerHealth(
.catch(() => ({ healthy: false })) .catch(() => ({ healthy: false }))
} }
const attempt = (count: number): Promise<ServerHealth> => const attempt = (count: number): Promise<ServerHealth> =>
createOpencodeClient({ createSdkForServer({
baseUrl: url, server,
fetch, fetch,
signal, signal,
}) })

View file

@ -0,0 +1,22 @@
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,
})
}

View file

@ -6,7 +6,10 @@ describe("terminalWriter", () => {
const calls: string[] = [] const calls: string[] = []
const scheduled: VoidFunction[] = [] const scheduled: VoidFunction[] = []
const writer = terminalWriter( const writer = terminalWriter(
(data) => calls.push(data), (data, done) => {
calls.push(data)
done?.()
},
(flush) => scheduled.push(flush), (flush) => scheduled.push(flush),
) )
@ -24,10 +27,38 @@ describe("terminalWriter", () => {
test("flush is a no-op when empty", () => { test("flush is a no-op when empty", () => {
const calls: string[] = [] const calls: string[] = []
const writer = terminalWriter( const writer = terminalWriter(
(data) => calls.push(data), (data, done) => {
calls.push(data)
done?.()
},
(flush) => flush(), (flush) => flush(),
) )
writer.flush() writer.flush()
expect(calls).toEqual([]) expect(calls).toEqual([])
}) })
test("flush waits for pending write completion", () => {
const calls: string[] = []
let done: VoidFunction | undefined
const writer = terminalWriter(
(data, finish) => {
calls.push(data)
done = finish
},
(flush) => flush(),
)
writer.push("a")
let settled = false
writer.flush(() => {
settled = true
})
expect(calls).toEqual(["a"])
expect(settled).toBe(false)
done?.()
expect(settled).toBe(true)
})
}) })

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