diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e1ff4241c9..e0e571b469 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -18,6 +18,7 @@ permissions: jobs: close-stale-prs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Close inactive PRs uses: actions/github-script@v8 @@ -25,6 +26,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" @@ -32,6 +42,42 @@ jobs: core.info(`Dry run mode: ${dryRun}`) core.info(`Cutoff date: ${cutoff.toISOString()}`) + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + const query = ` query($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -73,17 +119,27 @@ jobs: const allPrs = [] let cursor = null let hasNextPage = true + let pageCount = 0 while (hasNextPage) { - const result = await github.graphql(query, { - owner, - repo, - cursor, - }) + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) allPrs.push(...result.repository.pullRequests.nodes) hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } } core.info(`Found ${allPrs.length} open pull requests`) @@ -114,28 +170,66 @@ jobs: core.info(`Found ${stalePrs.length} stale pull requests`) + // ============================================ + // Close stale PRs + // ============================================ + const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedCount = 0 + let skippedCount = 0 + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) continue } - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }) + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }) + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) - core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + closedCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(requestDelayMs) + } catch (error) { + skippedCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedCount}`) + core.info(`PRs skipped (errors): ${skippedCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 2d726247c1..52fd004324 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -9,6 +9,7 @@ "options": {}, }, }, + "mcp": {}, "tools": { "github-triage": false, "github-pr-search": false, diff --git a/.prettierignore b/.prettierignore index aa3a7ce238..5f86f710fb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -sst-env.d.ts \ No newline at end of file +sst-env.d.ts +desktop/src/bindings.ts diff --git a/bun.lock b/bun.lock index ad44b07980..813545e58e 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -213,7 +213,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -242,7 +242,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -258,7 +258,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.48", + "version": "1.1.49", "bin": { "opencode": "./bin/opencode", }, @@ -286,7 +286,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -298,8 +298,8 @@ "@opencode-ai/sdk": "workspace:*", "@opencode-ai/util": "workspace:*", "@openrouter/ai-sdk-provider": "1.5.4", - "@opentui/core": "0.1.76", - "@opentui/solid": "0.1.76", + "@opentui/core": "0.1.77", + "@opentui/solid": "0.1.77", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -365,7 +365,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -385,7 +385,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.48", + "version": "1.1.49", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -396,7 +396,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -409,7 +409,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -451,7 +451,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "zod": "catalog:", }, @@ -462,7 +462,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -940,7 +940,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.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-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.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-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], @@ -1246,21 +1246,21 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], - "@opentui/core": ["@opentui/core@0.1.76", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="], + "@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.76", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.76", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.76", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.76", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.76", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.76", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="], - "@opentui/solid": ["@opentui/solid@0.1.76", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="], + "@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], diff --git a/nix/hashes.json b/nix/hashes.json index 9843699345..431148b1fd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=", - "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=", - "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=", - "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg=" + "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=", + "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=", + "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=", + "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM=" } } diff --git a/packages/app/package.json b/packages/app/package.json index e1232cb1ce..9808c36173 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.48", + "version": "1.1.49", "description": "", "type": "module", "exports": { diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index b9a7d6ed9b..6e7af3d902 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" -import { createMemo } from "solid-js" +import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps { onSelect: (result: string | string[] | null) => void } +type Row = { + absolute: string + search: string +} + export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const sync = useGlobalSync() const sdk = useGlobalSDK() const dialog = useDialog() const language = useLanguage() - const home = createMemo(() => sync.data.path.home) + const [filter, setFilter] = createSignal("") - const start = createMemo(() => sync.data.path.home || sync.data.path.directory) + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) const cache = new Map>>() + const clean = (value: string) => { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() + } + function normalize(input: string) { const v = input.replaceAll("\\", "/") if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") @@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return "" } - function display(path: string) { + function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) + } + + function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const + } + + function display(path: string, input: string) { const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + + return tildeOf(full) || full + } + + function tildeOf(absolute: string) { + const full = trimTrailing(absolute) const h = home() - if (!h) return full + if (!h) return "" const hn = trimTrailing(h) const lc = full.toLowerCase() const hc = hn.toLowerCase() if (lc === hc) return "~" if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return full + return "" } - function scoped(filter: string) { + function row(absolute: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full) + + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" + } + + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } + } + + function scoped(value: string) { const base = start() if (!base) return - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } const h = home() @@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const directories = async (filter: string) => { - const input = scoped(filter) - if (!input) return [] as string[] + const value = clean(filter) + const scopedInput = scoped(value) + if (!scopedInput) return [] as string[] - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(input.path) + const query = normalizeDriveRoot(scopedInput.path) - if (!isPath) { - const results = await sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) + const find = () => + sdk.client.find + .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) - return results.map((rel) => join(input.directory, rel)).slice(0, 50) + if (!isPath) { + const results = await find() + + return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const cap = 12 const branch = 4 - let paths = [input.directory] + let paths = [scopedInput.directory] for (const part of head) { if (part === "..") { - paths = paths.map((p) => { - const v = trimTrailing(p) - if (v === "/") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - return v.slice(0, i) - }) + paths = paths.map(parentOf) continue } @@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - return Array.from(new Set(out)).slice(0, 50) + const deduped = Array.from(new Set(out)) + const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" + const expand = !raw.endsWith("/") + if (!expand || !tail) { + const items = base ? Array.from(new Set([base, ...deduped])) : deduped + return items.slice(0, 50) + } + + const needle = tail.toLowerCase() + const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) + const target = exact[0] + if (!target) return deduped.slice(0, 50) + + const children = await match(target, "", 30) + const items = Array.from(new Set([...deduped, ...children])) + return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) + } + + const items = async (value: string) => { + const results = await directories(value) + return results.map(row) } function resolve(absolute: string) { @@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.directory.empty")} loadingMessage={language.t("common.loading")} - items={directories} - key={(x) => x} + items={items} + key={(x) => x.absolute} + filterKeys={["search"]} + ref={(r) => (list = r)} + onFilter={(value) => setFilter(clean(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = display(item.absolute, filter()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} onSelect={(path) => { if (!path) return - resolve(path) + resolve(path.absolute) }} > - {(absolute) => { - const path = display(absolute) + {(item) => { + const path = display(item.absolute, filter()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } return (
- +
{getDirectory(path)} {getFilename(path)} + /
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 2135b1edf4..4f0dcc3ee6 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,10 +90,9 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element | ((open: boolean) => JSX.Element) + children?: JSX.Element triggerAs?: T triggerProps?: ComponentProps - gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -176,14 +175,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={props.gutter ?? 8} + gutter={8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {typeof props.children === "function" ? props.children(store.open) : props.children} + {props.children} { + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + return out.toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) }) const Node = ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2b63b6f5fd..619d4e5d92 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { FileIcon } from "@opencode-ai/ui/file-icon" -import { MorphChevron } from "@opencode-ai/ui/morph-chevron" import { Button } from "@opencode-ai/ui/button" -import { CycleLabel } from "@opencode-ai/ui/cycle-label" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import type { IconName } from "@opencode-ai/ui/icons/provider" @@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" import { useProviders } from "@/hooks/use-providers" @@ -1257,7 +1254,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .shell({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, command: text, @@ -1280,7 +1277,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session?.id || "", + sessionID: session.id, command: commandName, arguments: args.join(" "), agent, @@ -1436,13 +1433,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session?.id || "", + sessionID: session.id, messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session?.id || "", + sessionID: session.id, role: "user", time: { created: Date.now() }, agent, @@ -1453,9 +1450,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1463,7 +1460,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) return @@ -1471,9 +1468,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1481,7 +1478,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) } @@ -1490,7 +1487,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1503,7 +1500,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1524,15 +1521,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "busy" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1549,7 +1546,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session?.id || "", { abort: controller, cleanup }) + pending.set(session.id, { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1577,7 +1574,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session?.id || "") + pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1587,7 +1584,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, messageID, @@ -1597,9 +1594,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session?.id || "") - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1621,28 +1618,6 @@ export const PromptInput: Component = (props) => { }) } - const currrentModelVariant = createMemo(() => { - const modelVariant = local.model.variant.current() ?? "" - return modelVariant === "xhigh" - ? "xHigh" - : modelVariant.length > 0 - ? modelVariant[0].toUpperCase() + modelVariant.slice(1) - : "Default" - }) - - const reasoningPercentage = createMemo(() => { - const variants = local.model.variant.list() - const current = local.model.variant.current() - const totalEntries = variants.length + 1 - - if (totalEntries <= 2 || current === "Default") { - return 0 - } - - const currentIndex = current ? variants.indexOf(current) + 1 : 0 - return ((currentIndex + 1) / totalEntries) * 100 - }, [local.model.variant]) - return (
@@ -1695,7 +1670,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1760,9 +1735,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1801,7 +1776,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1818,7 +1793,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) @@ -1848,7 +1823,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1921,8 +1896,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
@@ -1934,6 +1909,7 @@ export const PromptInput: Component = (props) => { @@ -1941,9 +1917,9 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class="capitalize" + class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`} + valueClass="truncate" variant="ghost" - gutter={12} /> = (props) => { fallback={ } > - - {(open) => ( - <> - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - )} + + + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + 0}> @@ -2018,7 +1996,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex items-center justify-center": true, + "_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), }} @@ -2040,7 +2018,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2083,7 +2060,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2094,7 +2071,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-5.5" + class="h-6 w-4.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 1e37d8f6a2..c5de54cf0f 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -64,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { } const circle = () => ( -
+
) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 06609fcfb8..516f3c8ede 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -3,11 +3,12 @@ import type { JSX } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { getFilename } from "@opencode-ai/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() + const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) return ( @@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v + v onClick={() => props.onTabClose(props.tab)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0251ed41b..b31cfb6cc7 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

@@ -232,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} > {(option) => ( @@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 8655bca34b..a24db13f5c 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => { }) return ( - +
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 0ee5caf73d..1807d561ea 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -40,12 +39,7 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2460534c05..dcc597139e 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
- +
) } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 001f7a5679..86b4fbeb1b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -24,6 +24,8 @@ export function Titlebar() { const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") + const zoom = () => platform.webviewZoom?.() ?? 1 + const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined) const [history, setHistory] = createStore({ stack: [] as string[], @@ -134,7 +136,7 @@ export function Titlebar() { return (
-
+
-
+
-
+
@@ -235,9 +233,8 @@ export function Titlebar() { "pr-6": !windows(), }} onMouseDown={drag} - data-tauri-drag-region > -
+
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 12fe37acf3..a42d5cbd5a 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -119,6 +119,8 @@ type ChildOptions = { bootstrap?: boolean } +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, @@ -297,7 +299,7 @@ function createGlobalSync() { const aUpdated = sessionUpdatedAt(a) const bUpdated = sessionUpdatedAt(b) if (aUpdated !== bUpdated) return bUpdated - aUpdated - return a.id.localeCompare(b.id) + return cmp(a.id, b.id) } function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { @@ -325,7 +327,7 @@ function createGlobalSync() { const all = input .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) const roots = all.filter((s) => !s.parentID) const children = all.filter((s) => !!s.parentID) @@ -342,7 +344,7 @@ function createGlobalSync() { return sessionUpdatedAt(s) > cutoff }) - return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id)) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) } function ensureChild(directory: string) { @@ -457,7 +459,7 @@ function createGlobalSync() { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) // Read the current limit at resolve-time so callers that bump the limit while // a request is in-flight still get the expanded result. @@ -559,7 +561,7 @@ function createGlobalSync() { "permission", sessionID, reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -588,7 +590,7 @@ function createGlobalSync() { "question", sessionID, reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)), + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -1003,7 +1005,7 @@ function createGlobalSync() { .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) setGlobalStore("project", projects) }), ), diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index f6fb157f06..591bd9c9fa 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -1,5 +1,6 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { AsyncStorage, SyncStorage } from "@solid-primitives/storage" +import type { Accessor } from "solid-js" export type Platform = { /** Platform discriminator */ @@ -55,6 +56,9 @@ export type Platform = { /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise + + /** Webview zoom level (desktop only) */ + webviewZoom?: Accessor } export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c39..0c63652450 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client" const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const next = items .map((x) => x.info) .filter((m) => !!m?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) batch(() => { input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) @@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ "part", message.info.id, reconcile( - message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)) + draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) }), ) }, @@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await client.session.list().then((x) => { const sessions = (x.data ?? []) .filter((s) => !!s?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) setStore("session", reconcile(sessions, { key: "id" })) }) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e3831e23c4..f816c9aca0 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "فتح الإعدادات", "command.session.previous": "الجلسة السابقة", "command.session.next": "الجلسة التالية", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "أرشفة الجلسة", "command.palette": "لوحة الأوامر", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", + "command.workspace.toggle": "تبديل مساحات العمل", "command.session.undo": "تراجع", "command.session.undo.description": "تراجع عن الرسالة الأخيرة", "command.session.redo": "إعادة", @@ -346,6 +349,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا", "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة", + "toast.workspace.enabled.title": "تم تمكين مساحات العمل", + "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", + "toast.workspace.disabled.title": "تم تعطيل مساحات العمل", + "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", + "toast.model.none.title": "لم يتم تحديد نموذج", "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f930a66aff..4bb66e11c9 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir configurações", "command.session.previous": "Sessão anterior", "command.session.next": "Próxima sessão", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arquivar sessão", "command.palette": "Paleta de comandos", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.permissions.autoaccept.enable": "Aceitar edições automaticamente", "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", + "command.workspace.toggle": "Alternar espaços de trabalho", "command.session.undo": "Desfazer", "command.session.undo.description": "Desfazer a última mensagem", "command.session.redo": "Refazer", @@ -345,6 +348,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente", "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação", + "toast.workspace.enabled.title": "Espaços de trabalho ativados", + "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral", + "toast.workspace.disabled.title": "Espaços de trabalho desativados", + "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral", + "toast.model.none.title": "Nenhum modelo selecionado", "toast.model.none.description": "Conecte um provedor para resumir esta sessão", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 2b7d77456d..95d9f4a0fc 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkivér session", "command.palette": "Kommandopalette", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.permissions.autoaccept.enable": "Accepter ændringer automatisk", "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", + "command.workspace.toggle": "Skift arbejdsområder", "command.session.undo": "Fortryd", "command.session.undo.description": "Fortryd den sidste besked", "command.session.redo": "Omgør", @@ -347,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse", + "toast.workspace.enabled.title": "Arbejdsområder aktiveret", + "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet", + "toast.workspace.disabled.title": "Arbejdsområder deaktiveret", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet", + "toast.model.none.title": "Ingen model valgt", "toast.model.none.description": "Forbind en udbyder for at opsummere denne session", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4648ad9c41..3ead99427d 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Sitzung archivieren", "command.palette": "Befehlspalette", @@ -72,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", + "command.workspace.toggle": "Arbeitsbereiche umschalten", "command.session.undo": "Rückgängig", "command.session.undo.description": "Letzte Nachricht rückgängig machen", "command.session.redo": "Wiederherstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 12ddcb4cd8..780c19e21c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", "command.palette": "Command palette", @@ -43,6 +45,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.file.open.description": "Search files and commands", + "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.terminal.toggle": "Toggle terminal", @@ -68,6 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Switch to the next effort level", "command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.workspace.toggle": "Toggle workspaces", + "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Undo", "command.session.undo.description": "Undo the last message", "command.session.redo": "Redo", @@ -347,6 +352,11 @@ export const dict = { "toast.theme.title": "Theme switched", "toast.scheme.title": "Color scheme", + "toast.workspace.enabled.title": "Workspaces enabled", + "toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar", + "toast.workspace.disabled.title": "Workspaces disabled", + "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar", + "toast.permissions.autoaccept.on.title": "Auto-accepting edits", "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5d396f0b4f..4c5fe30040 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archivar sesión", "command.palette": "Paleta de comandos", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", + "command.workspace.toggle": "Alternar espacios de trabajo", "command.session.undo": "Deshacer", "command.session.undo.description": "Deshacer el último mensaje", "command.session.redo": "Rehacer", @@ -348,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", + "toast.workspace.enabled.title": "Espacios de trabajo habilitados", + "toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral", + "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados", + "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral", + "toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4226d0c7e2..41c8b45547 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archiver la session", "command.palette": "Palette de commandes", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", + "command.workspace.toggle": "Basculer les espaces de travail", "command.session.undo": "Annuler", "command.session.undo.description": "Annuler le dernier message", "command.session.redo": "Rétablir", @@ -350,6 +353,11 @@ export const dict = { "toast.permissions.autoaccept.off.description": "Les permissions de modification et d'écriture nécessiteront une approbation", + "toast.workspace.enabled.title": "Espaces de travail activés", + "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale", + "toast.workspace.disabled.title": "Espaces de travail désactivés", + "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale", + "toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 28a925a0d3..d2530f5e51 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "セッションをアーカイブ", "command.palette": "コマンドパレット", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.disable": "編集の自動承認を停止", + "command.workspace.toggle": "ワークスペースを切り替え", "command.session.undo": "元に戻す", "command.session.undo.description": "最後のメッセージを元に戻す", "command.session.redo": "やり直す", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1be4e1eb4b..f81164ce3b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "세션 보관", "command.palette": "명령 팔레트", @@ -72,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.disable": "편집 자동 수락 중지", + "command.workspace.toggle": "작업 공간 전환", "command.session.undo": "실행 취소", "command.session.undo.description": "마지막 메시지 실행 취소", "command.session.redo": "다시 실행", @@ -349,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨", "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다", + "toast.workspace.enabled.title": "작업 공간 활성화됨", + "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다", + "toast.workspace.disabled.title": "작업 공간 비활성화됨", + "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다", + "toast.model.none.title": "선택된 모델 없음", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0a3b398856..d1f2bc7fdc 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -31,6 +31,8 @@ export const dict = { "command.settings.open": "Åpne innstillinger", "command.session.previous": "Forrige sesjon", "command.session.next": "Neste sesjon", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkiver sesjon", "command.palette": "Kommandopalett", @@ -71,6 +73,7 @@ export const dict = { "command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", + "command.workspace.toggle": "Veksle arbeidsområder", "command.session.undo": "Angre", "command.session.undo.description": "Angre siste melding", "command.session.redo": "Gjør om", @@ -349,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", + "toast.workspace.enabled.title": "Arbeidsområder aktivert", + "toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet", + "toast.workspace.disabled.title": "Arbeidsområder deaktivert", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet", + "toast.model.none.title": "Ingen modell valgt", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f4457c6acf..f1211c4599 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Otwórz ustawienia", "command.session.previous": "Poprzednia sesja", "command.session.next": "Następna sesja", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Zarchiwizuj sesję", "command.palette": "Paleta poleceń", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", + "command.workspace.toggle": "Przełącz przestrzenie robocze", "command.session.undo": "Cofnij", "command.session.undo.description": "Cofnij ostatnią wiadomość", "command.session.redo": "Ponów", @@ -347,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", + "toast.workspace.enabled.title": "Przestrzenie robocze włączone", + "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym", + "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone", + "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym", + "toast.model.none.title": "Nie wybrano modelu", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d5a4014d36..e0efffa41b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Открыть настройки", "command.session.previous": "Предыдущая сессия", "command.session.next": "Следующая сессия", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Архивировать сессию", "command.palette": "Палитра команд", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", + "command.workspace.toggle": "Переключить рабочие пространства", "command.session.undo": "Отменить", "command.session.undo.description": "Отменить последнее сообщение", "command.session.redo": "Повторить", @@ -348,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено", "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения", + "toast.workspace.enabled.title": "Рабочие пространства включены", + "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев", + "toast.workspace.disabled.title": "Рабочие пространства отключены", + "toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево", + "toast.model.none.title": "Модель не выбрана", "toast.model.none.description": "Подключите провайдера для суммаризации сессии", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1914b8e5bd..cfe439d510 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "เปิดการตั้งค่า", "command.session.previous": "เซสชันก่อนหน้า", "command.session.next": "เซสชันถัดไป", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "จัดเก็บเซสชัน", "command.palette": "คำสั่งค้นหา", @@ -68,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", + "command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.session.undo": "ยกเลิก", "command.session.undo.description": "ยกเลิกข้อความล่าสุด", "command.session.redo": "ทำซ้ำ", @@ -347,10 +350,15 @@ export const dict = { "toast.scheme.title": "โทนสี", "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ", + "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ", "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ", + "toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง", + "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง", + "toast.model.none.title": "ไม่ได้เลือกโมเดล", "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b9d5395730..81bb23db9d 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "归档会话", "command.palette": "命令面板", @@ -72,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切换到下一个强度等级", "command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑", + "command.workspace.toggle": "切换工作区", "command.session.undo": "撤销", "command.session.undo.description": "撤销上一条消息", "command.session.redo": "重做", @@ -342,7 +345,12 @@ export const dict = { "toast.language.description": "已切换到{{language}}", "toast.theme.title": "主题已切换", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "颜色方案", + + "toast.workspace.enabled.title": "工作区已启用", + "toast.workspace.enabled.description": "侧边栏现在显示多个工作树", + "toast.workspace.disabled.title": "工作区已禁用", + "toast.workspace.disabled.description": "侧边栏只显示主工作树", "toast.permissions.autoaccept.on.title": "自动接受编辑", "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 23d3d80e13..f01c1ce0b1 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "封存工作階段", "command.palette": "命令面板", @@ -72,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切換到下一個強度等級", "command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯", + "command.workspace.toggle": "切換工作區", "command.session.undo": "復原", "command.session.undo.description": "復原上一則訊息", "command.session.redo": "重做", @@ -339,7 +342,12 @@ export const dict = { "toast.language.description": "已切換到 {{language}}", "toast.theme.title": "主題已切換", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "顏色方案", + + "toast.workspace.enabled.title": "工作區已啟用", + "toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹", + "toast.workspace.disabled.title": "工作區已停用", + "toast.workspace.disabled.description": "側邊欄只顯示主工作樹", "toast.permissions.autoaccept.on.title": "自動接受編輯", "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 845a4fc834..2f963ae28d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,10 +27,12 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -108,7 +110,7 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const initialDir = params.dir + const initialDirectory = decode64(params.dir) const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { @@ -119,7 +121,7 @@ export default function Layout(props: ParentProps) { const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const [state, setState] = createStore({ - autoselect: !params.dir, + autoselect: !initialDirectory, busyWorkspaces: new Set(), hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, @@ -179,13 +181,21 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false - if (initialDir) return false if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() - if (list.length === 0) return false - return true + if (list.length > 0) return true + return !!server.projects.last() + }) + + createEffect(() => { + if (!state.autoselect) return + const dir = params.dir + if (!dir) return + const directory = decode64(dir) + if (!directory) return + setState("autoselect", false) }) const editorOpen = (id: string) => editor.active === id @@ -498,7 +508,7 @@ export default function Layout(props: ParentProps) { const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated @@ -565,11 +575,18 @@ export default function Layout(props: ParentProps) { if (!value.ready) return if (!value.layoutReady) return if (!state.autoselect) return - if (initialDir) return if (value.dir) return - if (value.list.length === 0) return const last = server.projects.last() + + if (value.list.length === 0) { + if (!last) return + setState("autoselect", false) + openProject(last, false) + navigateToProject(last) + return + } + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setState("autoselect", false) @@ -738,7 +755,7 @@ export default function Layout(props: ParentProps) { } async function prefetchMessages(directory: string, sessionID: string, token: number) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) + const [store, setStore] = globalSync.child(directory, { bootstrap: false }) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { @@ -749,23 +766,49 @@ export default function Layout(props: ParentProps) { .map((x) => x.info) .filter((m) => !!m?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + const current = store.message[sessionID] ?? [] + const merged = (() => { + if (current.length === 0) return next + + const map = new Map() + for (const item of current) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of next) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() batch(() => { - setStore("message", sessionID, reconcile(next, { key: "id" })) + setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { - setStore( - "part", - message.info.id, - reconcile( - message.parts + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = (() => { + if (currentParts.length === 0) { + return message.parts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map() + for (const item of currentParts) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of message.parts) { + if (!item?.id) continue + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) } }) }) @@ -886,6 +929,52 @@ export default function Layout(props: ParentProps) { queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } + function navigateSessionByUnseen(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + if (!hasUnseen) return + + const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex + + for (let i = 1; i <= sessions.length; i++) { + const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length + const session = sessions[index] + if (!session) continue + if (notification.session.unseen(session.id).length === 0) continue + + prefetchSession(session, "high") + + const next = sessions[(index + 1) % sessions.length] + const prev = sessions[(index - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + 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) + queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) + return + } + } + async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] @@ -1024,6 +1113,20 @@ export default function Layout(props: ParentProps) { keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, + { + id: "session.previous.unseen", + title: language.t("command.session.previous.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowup", + onSelect: () => navigateSessionByUnseen(-1), + }, + { + id: "session.next.unseen", + title: language.t("command.session.next.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowdown", + onSelect: () => navigateSessionByUnseen(1), + }, { id: "session.archive", title: language.t("command.session.archive"), @@ -1035,6 +1138,29 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.toggle", + title: language.t("command.workspace.toggle"), + description: language.t("command.workspace.toggle.description"), + category: language.t("command.category.workspace"), + slash: "workspace", + disabled: !currentProject() || currentProject()?.vcs !== "git", + onSelect: () => { + const project = currentProject() + if (!project) return + if (project.vcs !== "git") return + const wasEnabled = layout.sidebar.workspaces(project.worktree)() + layout.sidebar.toggleWorkspaces(project.worktree) + showToast({ + title: wasEnabled + ? language.t("toast.workspace.disabled.title") + : language.t("toast.workspace.enabled.title"), + description: wasEnabled + ? language.t("toast.workspace.disabled.description") + : language.t("toast.workspace.enabled.description"), + }) + }, + }, { id: "theme.cycle", title: language.t("command.theme.cycle"), @@ -2250,10 +2376,13 @@ export default function Layout(props: ParentProps) { () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), ) const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) + const active = createMemo( + () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), + ) createEffect(() => { if (preview()) return @@ -2291,50 +2420,95 @@ export default function Layout(props: ParentProps) { } const projectName = () => props.project.name || getFilename(props.project.worktree) - const trigger = ( - + { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onFocus={() => { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onClick={() => navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + dialog.show(() => )}> + {language.t("common.edit")} + + { + const enabled = layout.sidebar.workspaces(props.project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(props.project.worktree) + return + } + if (props.project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(props.project.worktree) + }} + > + + {layout.sidebar.workspaces(props.project.worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + ) return ( // @ts-ignore
- + }> } onOpenChange={(value) => { + if (menu()) return setOpen(value) if (value) setState("hoverSession", undefined) }} @@ -2532,6 +2706,14 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + type SearchItem = { + id: string + title: string + directory: string + label: string + archived?: number + } + const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2547,6 +2729,107 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) + const [search, setSearch] = createStore({ + value: "", + }) + const searching = createMemo(() => search.value.trim().length > 0) + let searchRef: HTMLInputElement | undefined + let listRef: ListRef | undefined + + const token = { value: 0 } + let inflight: Promise | undefined + let all: SearchItem[] | undefined + + const reset = () => { + token.value += 1 + inflight = undefined + all = undefined + setSearch({ value: "" }) + listRef = undefined + } + + const open = (item: SearchItem | undefined) => { + if (!item) return + + const href = `/${base64Encode(item.directory)}/session/${item.id}` + if (!layout.sidebar.opened()) { + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + reset() + navigate(href) + layout.mobileSidebar.hide() + } + + const items = (filter: string) => { + const query = filter.trim() + if (!query) { + token.value += 1 + inflight = undefined + all = undefined + return [] as SearchItem[] + } + + const project = panelProps.project + if (!project) return [] as SearchItem[] + if (all) return all + if (inflight) return inflight + + const current = token.value + const dirs = workspaceIds(project) + inflight = Promise.all( + dirs.map((input) => { + const directory = workspaceKey(input) + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + const label = `${kind} : ${name}` + return globalSDK.client.session + .list({ directory, roots: true }) + .then((x) => + (x.data ?? []) + .filter((s) => !!s?.id) + .map((s) => ({ + id: s.id, + title: s.title ?? language.t("command.session.new"), + directory, + label, + archived: s.time?.archived, + })), + ) + .catch(() => [] as SearchItem[]) + }), + ) + .then((results) => { + if (token.value !== current) return [] as SearchItem[] + + const seen = new Set() + const next = results.flat().filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + all = next + return next + }) + .catch(() => [] as SearchItem[]) + .finally(() => { + inflight = undefined + }) + + return inflight + } + + createEffect( + on( + () => panelProps.project?.worktree, + () => reset(), + { defer: true }, + ), + ) + return (
- + {(p) => ( <>
@@ -2564,7 +2847,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation @@ -2573,7 +2856,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -2592,31 +2875,31 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + dialog.show(() => )}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p.worktree)() + const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { - layout.sidebar.toggleWorkspaces(p.worktree) + layout.sidebar.toggleWorkspaces(p().worktree) return } - if (p.vcs !== "git") return - layout.sidebar.toggleWorkspaces(p.worktree) + if (p().vcs !== "git") return + layout.sidebar.toggleWorkspaces(p().worktree) }} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -2624,8 +2907,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -2635,103 +2918,207 @@ export default function Layout(props: ParentProps) {
- +
{ + const target = event.target + if (!(target instanceof Element)) return + if (target.closest("input, textarea, [contenteditable='true']")) return + searchRef?.focus() + }} + > + + { + searchRef = el + }} + class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" + style={{ "box-shadow": "none" }} + value={search.value} + onInput={(event) => setSearch("value", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + return + } + + if (!searching()) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + return + } + + if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + if (event.key === "n" || event.key === "p") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + } + } + }} + placeholder={language.t("session.header.search.placeholder", { project: projectName() })} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + { + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + }} + /> + +
+
+ + + `${item.directory}:${item.id}`} + onSelect={open} + ref={(ref) => { + listRef = ref + }} + > + {(item) => ( +
+ + {item.title} + + + {item.label} + +
+ )} +
+
+ +
+ +
+ + + +
+
+ +
+ + } + > <> -
+
-
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + + +
- } - > - <> -
- - - -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - - -
-
- - + +
)} - 0 && providers.paid().length === 0}> -
-
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
-
- + +
0 && providers.paid().length === 0), + }} + > +
+
+
{language.t("sidebar.gettingStarted.title")}
+
{language.t("sidebar.gettingStarted.line1")}
+
{language.t("sidebar.gettingStarted.line2")}
+
- +
) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d3e74072a8..540046c09b 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -500,9 +500,7 @@ export default function Page() { const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) - const add = diff.additions > 0 - const del = diff.deletions > 0 - const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) @@ -689,6 +687,18 @@ export default function Page() { slash: "open", onSelect: () => dialog.show(() => showAllFiles()} />), }, + { + id: "tab.close", + title: language.t("command.tab.close"), + category: language.t("command.category.file"), + keybind: "mod+w", + disabled: !tabs().active(), + onSelect: () => { + const active = tabs().active() + if (!active) return + tabs().close(active) + }, + }, { id: "context.addSelection", title: language.t("command.context.addSelection"), @@ -1940,7 +1950,8 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), }} >
@@ -1968,7 +1979,8 @@ export default function Page() { class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2021,7 +2033,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200": centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), }} > diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 83405bf634..88bf7a48ba 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.48", + "version": "1.1.49", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 1d99def1b9..be53ad909b 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "80K", - full: "80,000", + compact: "95K", + full: "95,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "600", - commits: "7,500", - monthlyUsers: "1.5M", + contributors: "650", + commits: "8,500", + monthlyUsers: "2.5M", }, } as const diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 244db072c6..d54bd0306d 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" import { RateLimitError } from "./error" import { logger } from "./logger" +import { ZenData } from "@opencode-ai/console-core/model.js" -export function createRateLimiter(limit: number | undefined, rawIp: string) { +export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) { if (!limit) return const ip = !rawIp.length ? "unknown" : rawIp const now = Date.now() - const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] + const intervals = + limit.period === "day" + ? [buildYYYYMMDD(now)] + : [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)] return { track: async () => { @@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) { ) const total = rows.reduce((sum, r) => sum + r.count, 0) logger.debug(`rate limit total: ${total}`) - if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) + if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) }, } } +function buildYYYYMMDD(timestamp: number) { + return new Date(timestamp) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 8) +} + function buildYYYYMMDDHH(timestamp: number) { return new Date(timestamp) .toISOString() diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 35f2b70991..26d870bd56 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.48", + "version": "1.1.49", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 880c63a190..fc9674cedb 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -18,8 +18,13 @@ export namespace ZenData { }), ), }) + const RateLimitSchema = z.object({ + period: z.enum(["day", "rolling"]), + value: z.number().int(), + }) export type Format = z.infer export type Trial = z.infer + export type RateLimit = z.infer const ModelCostSchema = z.object({ input: z.number(), @@ -37,7 +42,7 @@ export namespace ZenData { byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(), trial: TrialSchema.optional(), - rateLimit: z.number().optional(), + rateLimit: RateLimitSchema.optional(), fallbackProvider: z.string().optional(), providers: z.array( z.object({ diff --git a/packages/console/function/package.json b/packages/console/function/package.json index e04aa5e310..6acecac63b 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.48", + "version": "1.1.49", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1f69a0c75c..122f362ad6 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.48", + "version": "1.1.49", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/index.html b/packages/desktop/index.html index a5a8c2571f..6a81ef4a50 100644 --- a/packages/desktop/index.html +++ b/packages/desktop/index.html @@ -17,7 +17,7 @@ -
+