Compare commits

..
Author SHA1 Message Date
Simon KleeandGitHub 53f7a1c63c Merge branch 'dev' into oc-snapshot-subdir 2026-05-18 13:05:17 +02:00
Simon Klee 4a608b2026 snapshot: fix cwd for git commands in subdirs
When opencode is launched from a git repository subdirectory,
the snapshot service passes file paths relative to the worktree
root but sets cwd to the launch directory. Git cannot resolve
the paths and fails with "did not match any files".

Use the worktree root as cwd so paths resolve correctly
regardless of which subdirectory opencode is started from.

Fix #27688
Close #27737
2026-05-18 09:58:08 +02:00
1038 changed files with 20738 additions and 104636 deletions
-12
View File
@@ -1,12 +0,0 @@
.git
.opencode
.sst
.turbo
.wrangler
node_modules
**/node_modules
**/.output
**/dist
**/.turbo
**/.vite
**/coverage
-1
View File
@@ -14,4 +14,3 @@ rekram1-node
thdxr
simonklee
vimtor
starptech
+8 -11
View File
@@ -9,15 +9,9 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
id-token: write
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -27,11 +21,14 @@ jobs:
with:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
aws-region: us-east-1
# Workaround for Pulumi version conflict:
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
# Removing the system language plugin forces SST to use its bundled compatible version.
# TODO: Remove when sst supports Pulumi >3.210.0
- name: Fix Pulumi version conflict
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
+2 -2
View File
@@ -4,8 +4,8 @@ import { tool } from "@opencode-ai/plugin"
const TEAM = {
tui: ["kommander", "simonklee"],
desktop_web: ["Hona", "Brendonovich"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
inference: ["fwang", "MrMushrooooom", "starptech"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
inference: ["fwang", "MrMushrooooom"],
windows: ["Hona"],
} as const
+2 -8
View File
@@ -1,14 +1,8 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Style Guide
+174 -368
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: {
worker: (args) => {
args.logpush = true
if ($app.stage === "vimtor" || $app.stage === "adam") return
if ($app.stage === "vimtor") return
args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings,
{
+3 -7
View File
@@ -1,9 +1,7 @@
import { deployAws, domain } from "./stage"
import { domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
const lake = deployAws ? await import("./lake") : undefined
////////////////
// DATABASE
////////////////
@@ -242,7 +240,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])],
link: [new sst.Secret("HONEYCOMB_API_KEY")],
})
new sst.cloudflare.x.SolidStart("Console", {
@@ -252,8 +250,6 @@ new sst.cloudflare.x.SolidStart("Console", {
bucket,
bucketNew,
database,
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL,
@@ -285,7 +281,7 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-2" },
placement: { region: "aws:us-east-1" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
-322
View File
@@ -1,322 +0,0 @@
import { domain } from "./stage"
const current = aws.getCallerIdentityOutput({})
const partition = aws.getPartitionOutput({})
const region = aws.getRegionOutput({})
const tableBucketName = `opencode-${$app.stage}-lake`
const glueCatalogName = "s3tablescatalog"
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
name: tableBucketName,
forceDestroy: $app.stage !== "production",
})
const s3TablesCatalog = new aws.cloudcontrol.Resource(
"LakeS3TablesCatalog",
{
typeName: "AWS::Glue::Catalog",
desiredState: $jsonStringify({
Name: glueCatalogName,
Description: "Federated catalog for S3 Tables",
FederatedCatalog: {
Identifier: s3TablesBucketWildcardArn,
ConnectionName: "aws:s3tables",
},
CreateDatabaseDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
CreateTableDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
AllowFullTableExternalDataAccess: "True",
}),
},
{ dependsOn: [tableBucket] },
)
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
bucket: `opencode-${$app.stage}-lake-athena-results`,
forceDestroy: $app.stage !== "production",
})
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
forceDestroy: $app.stage !== "production",
})
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
name: `opencode-${$app.stage}-lake-workgroup`,
forceDestroy: $app.stage !== "production",
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
},
})
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRole"],
principals: [
{
type: "Service",
identifiers: ["firehose.amazonaws.com"],
},
],
},
],
}).json,
})
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
role: firehoseRole.id,
policy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: [
"s3tables:ListTableBuckets",
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
"s3tables:PutTableData",
"s3tables:UpdateTableMetadataLocation",
],
resources: ["*"],
},
{
effect: "Allow",
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:UpdateTable",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
effect: "Allow",
actions: [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
],
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
},
{
effect: "Allow",
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
],
}).json,
})
const firehose = new aws.kinesis.FirehoseDeliveryStream(
"LakeFirehose",
{
name: `opencode-${$app.stage}-lake-ingest`,
destination: "iceberg",
icebergConfiguration: {
appendOnly: true,
bufferingInterval: 60,
bufferingSize: 1,
catalogArn: glueS3TablesChildCatalogArn,
processingConfiguration: {
enabled: true,
processors: [
{
type: "MetadataExtraction",
parameters: [
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
{
parameterName: "MetadataExtractionQuery",
parameterValue:
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
},
],
},
],
},
roleArn: firehoseRole.arn,
s3BackupMode: "FailedDataOnly",
s3Configuration: {
roleArn: firehoseRole.arn,
bucketArn: firehoseErrorBucket.arn,
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
},
},
},
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
)
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
export const lakeRegion = region.region
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
export const lakeAthenaWorkgroup = athenaWorkgroup
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
properties: {
streamName: firehose.name,
secret: ingestSecret.result,
},
})
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "1 vCPU",
memory: "4 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
link: [ingestConfig],
permissions: [
{
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
resources: [firehose.arn],
},
],
scaling: {
min: $app.stage === "production" ? 2 : 1,
max: $app.stage === "production" ? 32 : 4,
cpuUtilization: 60,
memoryUtilization: 70,
},
loadBalancer: {
domain: {
name: `lake.${domain}`,
dns: sst.cloudflare.dns(),
},
rules: [
{ listen: "80/http", redirect: "443/https" },
{ listen: "443/https", forward: "3000/http" },
],
health: {
"3000/http": {
path: "/ready",
successCodes: "200-299",
},
},
},
health: {
command: [
"CMD-SHELL",
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
],
interval: "30 seconds",
retries: 3,
startPeriod: "30 seconds",
timeout: "5 seconds",
},
dev: {
command: "bun run start",
directory: "packages/stats/server",
url: "http://localhost:3000",
},
wait: $app.stage === "production",
})
export const lakeIngest = new sst.Linkable("LakeIngest", {
properties: {
url: ingestService.url,
secret: ingestSecret.result,
},
})
export const lakeQueryPermissions = [
{
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
resources: [athenaWorkgroup.arn],
},
{
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:GetPartitions",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
resources: [athenaResultsBucket.arn],
},
{
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
},
{
actions: [
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
],
resources: ["*"],
},
{
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
]
+13 -20
View File
@@ -2,9 +2,8 @@ import { SECRET } from "./secret"
import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycombio.WebhookRecipient("DiscordAlerts", {
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`,
secret: SECRET.HoneycombWebhookSecret.result,
@@ -67,7 +66,7 @@ IF(
)`,
})
return honeycombio.getQuerySpecificationOutput({
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"],
calculatedFields: [failedHttpStatus],
calculations: [
@@ -80,7 +79,7 @@ IF(
filters,
},
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
}).json
}
@@ -99,7 +98,7 @@ const providerHttpErrorsQuery = () => {
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
})
return honeycombio.getQuerySpecificationOutput({
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"],
calculatedFields: [successHttpStatus, failedProviderHttpStatus],
calculations: [
@@ -123,7 +122,7 @@ const providerHttpErrorsQuery = () => {
},
],
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 900,
}).json
@@ -140,7 +139,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
{ column: "tps.output", op: "exists" },
]
return honeycombio.getQuerySpecificationOutput({
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"],
calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
@@ -157,10 +156,9 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
}).json
}
new honeycombio.Trigger("IncreasedModelHttpErrorsGo", {
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]",
description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
@@ -177,10 +175,9 @@ new honeycombio.Trigger("IncreasedModelHttpErrorsGo", {
],
})
new honeycombio.Trigger("IncreasedModelHttpErrorsZen", {
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]",
description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 300,
@@ -197,10 +194,9 @@ new honeycombio.Trigger("IncreasedModelHttpErrorsZen", {
],
})
new honeycombio.Trigger("LowModelTpsGo", {
new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]",
description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("go"),
alertType: "on_change",
frequency: 600,
@@ -217,10 +213,9 @@ new honeycombio.Trigger("LowModelTpsGo", {
],
})
new honeycombio.Trigger("LowModelTpsZen", {
new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]",
description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("zen"),
alertType: "on_change",
frequency: 600,
@@ -237,10 +232,9 @@ new honeycombio.Trigger("LowModelTpsZen", {
],
})
new honeycombio.Trigger("IncreasedProviderHttpErrors", {
new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors",
description,
disabled: alertsDisabled,
queryJson: providerHttpErrorsQuery(),
alertType: "on_change",
frequency: 300,
@@ -257,11 +251,10 @@ new honeycombio.Trigger("IncreasedProviderHttpErrors", {
],
})
new honeycombio.Trigger("IncreasedFreeTierRequests", {
new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests",
description,
disabled: alertsDisabled,
queryJson: honeycombio.getQuerySpecificationOutput({
queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }],
filters: [
{ column: "event_type", op: "=", value: "completions" },
-3
View File
@@ -7,8 +7,5 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
-2
View File
@@ -5,8 +5,6 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
-207
View File
@@ -1,207 +0,0 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
const domain = (() => {
if ($app.stage === "production") return "stats.opencode.ai"
if ($app.stage === "dev") return "stats.dev.opencode.ai"
return `stats.${$app.stage}.dev.opencode.ai`
})()
////////////////
// LAKE
////////////////
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
namespace: "inference",
tableBucketArn: tableBucket.arn,
})
const inferenceEventTable = new aws.s3tables.Table(
"LakeInferenceEventTable",
{
name: "event",
namespace: inferenceNamespace.namespace,
tableBucketArn: inferenceNamespace.tableBucketArn,
format: "ICEBERG",
metadata: {
iceberg: {
schema: {
fields: [
{ name: "event_timestamp", type: "string", required: false },
{ name: "event_date", type: "string", required: false },
{ name: "event_type", type: "string", required: false },
{ name: "dataset", type: "string", required: false },
{ name: "cf_continent", type: "string", required: false },
{ name: "cf_country", type: "string", required: false },
{ name: "cf_city", type: "string", required: false },
{ name: "cf_region", type: "string", required: false },
{ name: "cf_latitude", type: "double", required: false },
{ name: "cf_longitude", type: "double", required: false },
{ name: "cf_timezone", type: "string", required: false },
{ name: "duration", type: "double", required: false },
{ name: "request_length", type: "long", required: false },
{ name: "status", type: "int", required: false },
{ name: "ip", type: "string", required: false },
{ name: "is_stream", type: "boolean", required: false },
{ name: "session", type: "string", required: false },
{ name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false },
{ name: "error_type", type: "string", required: false },
{ name: "error_message", type: "string", required: false },
{ name: "error_cause", type: "string", required: false },
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
{ name: "time_to_first_byte", type: "long", required: false },
{ name: "timestamp_first_byte", type: "long", required: false },
{ name: "timestamp_last_byte", type: "long", required: false },
{ name: "tokens_input", type: "long", required: false },
{ name: "tokens_output", type: "long", required: false },
{ name: "tokens_reasoning", type: "long", required: false },
{ name: "tokens_cache_read", type: "long", required: false },
{ name: "tokens_cache_write_5m", type: "long", required: false },
{ name: "tokens_cache_write_1h", type: "long", required: false },
{ name: "cost_input_microcents", type: "long", required: false },
{ name: "cost_output_microcents", type: "long", required: false },
{ name: "cost_cache_read_microcents", type: "long", required: false },
{ name: "cost_cache_write_microcents", type: "long", required: false },
{ name: "cost_total_microcents", type: "long", required: false },
{ name: "cost_input", type: "long", required: false },
{ name: "cost_output", type: "long", required: false },
{ name: "cost_cache_read", type: "long", required: false },
{ name: "cost_cache_write_5m", type: "long", required: false },
{ name: "cost_cache_write_1h", type: "long", required: false },
{ name: "cost_total", type: "long", required: false },
],
},
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
properties: {
region: lakeRegion,
catalog: lakeCatalog,
database: inferenceNamespace.namespace,
table: inferenceEventTable.name,
tableBucket: tableBucket.name,
workgroup: lakeAthenaWorkgroup.name,
},
})
////////////////
// DATABASE
////////////////
const cluster = planetscale.getDatabaseOutput({
name: "opencode-stats",
organization: "anomalyco",
})
const branch =
$app.stage === "production"
? planetscale.getBranchOutput({
name: "production",
organization: cluster.organization,
database: cluster.name,
})
: new planetscale.Branch("StatsDatabaseBranch", {
database: cluster.name,
organization: cluster.organization,
name: $app.stage,
parentBranch: "production",
})
const password = new planetscale.Password("StatsDatabasePassword", {
name: $app.stage,
database: cluster.name,
organization: cluster.organization,
branch: branch.name,
})
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
encodeURIComponent,
)}@${password.accessHostUrl}/${cluster.name}`
export const database = new sst.Linkable("StatsDatabase", {
properties: {
host: password.accessHostUrl,
database: cluster.name,
username: password.username,
password: password.plaintext,
port: 3306,
url: databaseUrl,
},
})
new sst.x.DevCommand("StatsStudio", {
link: [database],
environment: {
DATABASE_URL: databaseUrl,
},
dev: {
command: "bun db:studio",
directory: "packages/stats/core",
autostart: false,
},
})
////////////////
// APP
////////////////
// export const app = new sst.cloudflare.x.SolidStart("Stats", {
// path: "packages/stats/app",
// buildCommand: "bun run build",
// domain,
// link: [database],
// environment: {
// PUBLIC_URL: `https://${domain}`,
// },
// })
////////////////
// SERVICES
////////////////
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
properties: {
dataset: "zen",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
max: 1,
},
dev: {
command: "bun src/stat-sync.ts",
directory: "packages/stats/server",
autostart: false,
},
})
+1 -10
View File
@@ -3,7 +3,6 @@
stdenv,
bun,
nodejs,
darwin,
electron_41,
makeWrapper,
writableTmpDirAsHomeHook,
@@ -15,12 +14,7 @@ let
in
stdenv.mkDerivation (finalAttrs: {
pname = "opencode-desktop";
inherit (opencode)
version
src
node_modules
patches
;
inherit (opencode) version src node_modules;
nativeBuildInputs = [
bun
@@ -29,9 +23,6 @@ stdenv.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
] ++ lib.optionals stdenv.hostPlatform.isLinux [
autoPatchelfHook
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
darwin.autoSignDarwinBinariesHook
];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
"x86_64-linux": "sha256-LN6VLX8bXADSPGt67+YjP1Sy0QdQY+HFPq8iXN5nkck=",
"aarch64-linux": "sha256-3yhpjuCLwi7KCZZvsfabtc50hgznVfD8rcMZ9/JAnSs=",
"aarch64-darwin": "sha256-6spcWVHVBuM0SlWYTFrDvForGbtxFXxeVvJMC9thN+g=",
"x86_64-darwin": "sha256-hnD68Dexq/3EP/Sm3MnL88ezIvLLa6/nVzaV+bRpn9w="
}
}
+11 -18
View File
@@ -10,7 +10,6 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -18,29 +17,27 @@
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"hello": "echo 'Hello World!'",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
"catalog": {
"@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@effect/opentelemetry": "4.0.0-beta.65",
"@effect/platform-node": "4.0.0-beta.65",
"@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@opentui/core": "0.2.13",
"@opentui/keymap": "0.2.13",
"@opentui/solid": "0.2.13",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -56,9 +53,9 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.66",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.65",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
@@ -74,7 +71,6 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
@@ -101,7 +97,7 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"sst": "3.18.10",
"turbo": "2.8.13"
},
"dependencies": {
@@ -142,9 +138,6 @@
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}
@@ -1,352 +0,0 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/TimelineStateRegression"
const projectID = "proj_timeline_state_regression"
const sessionID = "ses_timeline_state_regression"
const userMessageID = "msg_user_regression"
const assistantMessageID = "msg_assistant_regression"
const editPartID = "prt_0001_edit"
const textPartID = "prt_9999_text"
const title = "Timeline collapse state regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type EventPayload = {
directory: string
payload: Record<string, unknown>
}
declare global {
interface Window {
__timelineDiffProbe: {
reset: () => void
shadowRoots: () => number
}
}
}
const userMessage = {
info: {
id: userMessageID,
sessionID,
role: "user",
time: { created: 1700000000000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [
{
id: "prt_user_text",
sessionID,
messageID: userMessageID,
type: "text",
text: "Please edit the file.",
},
],
}
const editPart = {
id: editPartID,
sessionID,
messageID: assistantMessageID,
type: "tool",
callID: "call_edit_regression",
tool: "edit",
state: {
status: "completed",
input: { filePath: "src/regression.ts" },
output: "Edited src/regression.ts",
title: "src/regression.ts",
metadata: {
filediff: {
file: "src/regression.ts",
additions: 1,
deletions: 1,
before: "export const value = 'before'\n",
after: "export const value = 'after'\n",
},
diff: "diff --git a/src/regression.ts b/src/regression.ts\n-export const value = 'before'\n+export const value = 'after'\n",
},
time: { start: 1700000001000, end: 1700000002000 },
},
}
const streamedTextPart = {
id: textPartID,
sessionID,
messageID: assistantMessageID,
type: "text",
text: "Streaming added a later assistant text part.",
}
const assistantMessage = {
info: {
id: assistantMessageID,
sessionID,
role: "assistant",
time: { created: 1700000001000 },
parentID: userMessageID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
},
parts: [editPart],
}
test.describe("regression: session timeline local row state", () => {
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
const events: EventPayload[] = []
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expectExpanded(wrapper, true)
await wrapper.evaluate((element) => {
;(element as HTMLElement).dataset.regressionMarker = "before-stream"
})
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
await expectExpanded(wrapper, false)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readToolState(page)).toEqual({
expanded: false,
row: "AssistantPart",
streamedTextVisible: true,
})
})
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
const events: EventPayload[] = []
await installDiffProbe(page)
await mockServer(page, events)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
await expect(wrapper).toBeVisible()
await expect(wrapper.locator('[data-component="file"][data-mode="diff"]').first()).toBeVisible()
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: streamedTextPart },
},
})
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
await markDiffProbe(page)
events.push({
directory,
payload: {
type: "message.part.updated",
properties: { part: editPartWithAdditions(2) },
},
})
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
{ timeout: 10_000 },
)
expect(await readDiffProbe(page)).toEqual({ fileMarker: "before", shadowRoots: 0, toolMarker: "before" })
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function expectExpanded(locator: Locator, expected: boolean) {
await expect.poll(() => locator.evaluate(readExpanded)).toBe(expected)
}
async function readToolState(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate(
(element, textPartID) => ({
expanded: (() => {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
})(),
row: element.closest("[data-timeline-row]")?.getAttribute("data-timeline-row"),
streamedTextVisible: !!document.querySelector(`[data-timeline-part-id="${textPartID}"]`),
}),
textPartID,
)
}
async function installDiffProbe(page: Page) {
await page.addInitScript(() => {
let shadowRootCount = 0
const attachShadow = Element.prototype.attachShadow
Element.prototype.attachShadow = function (init) {
shadowRootCount += 1
return attachShadow.call(this, init)
}
window.__timelineDiffProbe = {
reset: () => {
shadowRootCount = 0
},
shadowRoots: () => shadowRootCount,
}
})
}
async function markDiffProbe(page: Page) {
await page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
if (!file) throw new Error("missing edit diff file")
tool.dataset.timelineProbe = "before"
file.dataset.timelineProbe = "before"
window.__timelineDiffProbe.reset()
})
}
async function readDiffProbe(page: Page) {
return page
.locator(`[data-timeline-part-id="${editPartID}"]`)
.first()
.evaluate((element) => {
const tool = element as HTMLElement
const file = tool.querySelector<HTMLElement>('[data-component="file"][data-mode="diff"]')
return {
fileMarker: file?.dataset.timelineProbe,
shadowRoots: window.__timelineDiffProbe.shadowRoots(),
toolMarker: tool.dataset.timelineProbe,
}
})
}
function editPartWithAdditions(additions: number) {
return {
...editPart,
state: {
...editPart.state,
metadata: {
...editPart.state.metadata,
filediff: {
...editPart.state.metadata.filediff,
additions,
},
},
},
}
}
function readExpanded(element: Element) {
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
const aria = trigger?.getAttribute("aria-expanded")
if (aria === "true") return true
if (aria === "false") return false
const root = element.querySelector('[data-component="collapsible"]')
if (root?.hasAttribute("data-expanded")) return true
if (root?.hasAttribute("data-closed")) return false
const content = element.querySelector<HTMLElement>('[data-slot="collapsible-content"]')
return !!content && content.getBoundingClientRect().height > 0
}
async function mockServer(page: Page, events: EventPayload[]) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: [userMessage, assistantMessage] }),
events: () => events.splice(0),
})
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "timeline-state-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "timeline-state-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,267 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
const directory = "C:/OpenCode/ContextResizeRegression"
const projectID = "proj_context_resize_regression"
const sessionID = "ses_context_resize_regression"
const title = "Context resize regression"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
const contextIDs = ["prt_0100_read", "prt_0101_glob", "prt_0102_grep", "prt_0103_list"]
const followingTextID = "prt_0104_text"
type Message = {
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
parts: Record<string, unknown>[]
}
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
test.describe("regression: session timeline context group resize", () => {
test("remeasures a recent explored context group before the next paint", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await mockServer(page)
await configurePage(page)
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expect(page.getByRole("heading", { name: title })).toBeVisible()
await expect(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first()).toBeVisible()
await settle(page)
const samples = await sampleExpansion(page)
const visibleOverlap = samples.filter((sample) => sample.frame >= 1 && sample.overlap > 0.5)
console.log("context resize samples", JSON.stringify(samples, null, 2))
expect(samples[0]?.overlap).toBe(0)
expect(visibleOverlap).toEqual([])
expect(samples.at(-1)?.expanded).toBe("true")
})
})
async function configurePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
}
async function sampleExpansion(page: Page) {
return page.evaluate(
({ contextIDs, followingTextID }) =>
new Promise<
{
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[]
>((resolve) => {
const context = document.querySelector<HTMLElement>(`[data-timeline-part-ids="${contextIDs.join(",")}"]`)
const text = document.querySelector<HTMLElement>(`[data-timeline-part-id="${followingTextID}"]`)
const scroller = context?.closest<HTMLElement>(".scroll-view__viewport")
const trigger = context?.querySelector<HTMLElement>('[data-slot="collapsible-trigger"]')
const contextRow = context?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
const textRow = text?.closest<HTMLElement>('[data-timeline-row="AssistantPart"]')
if (!context || !text || !scroller || !trigger || !contextRow || !textRow)
throw new Error("missing regression nodes")
scroller.scrollTop = scroller.scrollHeight
const samples: {
frame: number
label: string
scrollTop: number
scrollHeight: number
contextBottom: number
textTop: number
overlap: number
gap: number
expanded: string | null
}[] = []
const capture = (frame: number, label: string) => {
const contextRect = contextRow.getBoundingClientRect()
const textRect = textRow.getBoundingClientRect()
samples.push({
frame,
label,
scrollTop: Math.round(scroller.scrollTop * 10) / 10,
scrollHeight: Math.round(scroller.scrollHeight * 10) / 10,
contextBottom: Math.round(contextRect.bottom * 10) / 10,
textTop: Math.round(textRect.top * 10) / 10,
overlap: Math.max(0, Math.round((contextRect.bottom - textRect.top) * 10) / 10),
gap: Math.max(0, Math.round((textRect.top - contextRect.bottom) * 10) / 10),
expanded: trigger.getAttribute("aria-expanded"),
})
}
capture(-1, "before")
trigger.click()
capture(0, "sync-after-click")
let frame = 1
const tick = () => {
capture(frame, "raf")
frame += 1
if (frame > 8) {
resolve(samples)
return
}
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
}),
{ contextIDs, followingTextID },
)
}
function turn(index: number, target: boolean): Message[] {
const userID = id("msg_user", index)
const assistantID = id("msg_assistant", index)
return [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs: [] },
agent: "build",
model,
},
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
parentID: userID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: target
? [
contextTool(contextIDs[0]!, assistantID, "read", { filePath: "src/recent-a.ts", offset: 0, limit: 120 }),
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }),
contextTool(contextIDs[2]!, assistantID, "grep", { path: directory, pattern: "Explored", include: "*.ts" }),
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }),
{
id: followingTextID,
sessionID,
messageID: assistantID,
type: "text",
text: "This assistant text is immediately after the explored context group.",
},
]
: [
{
id: id("prt_text", index),
sessionID,
messageID: assistantID,
type: "text",
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
},
],
},
]
}
function contextTool(partID: string, messageID: string, tool: string, input: Record<string, unknown>) {
return {
id: partID,
sessionID,
messageID,
type: "tool",
callID: `call_${partID}`,
tool,
state: {
status: "completed",
input,
output: `Completed ${tool}.\n${"detail line\n".repeat(8)}`,
title: input.filePath || input.path || input.pattern || "completed",
metadata: {},
time: { start: 1700000000000, end: 1700000000100 },
},
}
}
async function mockServer(page: Page) {
await mockOpenCodeServer(page, {
directory,
project: project(),
provider: provider(),
sessions: [session()],
pageMessages: () => ({ items: messages }),
})
}
async function settle(page: Page) {
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
}
function id(prefix: string, index: number) {
return `${prefix}_${String(index).padStart(4, "0")}`
}
function project() {
return {
id: projectID,
worktree: directory,
vcs: "git",
name: "context-resize-regression",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
}
}
function session() {
return {
id: sessionID,
slug: "context-resize-regression",
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
}
}
function provider() {
return {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
}
}
function base64Encode(value: string) {
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
@@ -1,314 +0,0 @@
const words = [
"alpha",
"bravo",
"charlie",
"delta",
"echo",
"foxtrot",
"golf",
"hotel",
"india",
"juliet",
"kilo",
"lima",
"metro",
"nova",
"orbit",
"pixel",
"quartz",
"river",
"signal",
"vector",
]
const sourceID = "ses_smoke_source"
const targetID = "ses_smoke_target"
const directory = "C:/OpenCode/SmokeProject"
const projectID = "proj_smoke_timeline"
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
type Message = { info: MessageInfo; parts: MessagePart[] }
function lorem(seed: number, length: number) {
let out = ""
let i = seed
while (out.length < length) {
const word = words[i % words.length]
out += (out ? " " : "") + word
if (i % 17 === 0) out += ".\n\n"
i += 7
}
return out.slice(0, length)
}
function id(prefix: string, value: number) {
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
}
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
const messageID = id("msg_user", index)
return {
info: {
id: messageID,
sessionID,
role: "user",
time: { created: 1700000000000 + index * 10_000 },
summary: { diffs },
agent: "build",
model,
},
parts: [
{
id: id("prt_user_text", index),
sessionID,
messageID,
type: "text",
text: lorem(index, textLength),
},
],
}
}
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
const messageID = id("msg_assistant", index)
return {
info: {
id: messageID,
sessionID,
role: "assistant",
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
parentID,
modelID: model.modelID,
providerID: model.providerID,
mode: "build",
agent: "build",
path: { cwd: directory, root: directory },
cost: 0.01,
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
variant: "max",
finish: "stop",
},
parts: parts.map((part) => ({
...part,
sessionID,
messageID,
})),
}
}
function textPart(index: number, partIndex: number, length: number): MessagePart {
return { id: id(`prt_text_${partIndex}`, index), type: "text", text: lorem(index * 13 + partIndex, length) }
}
function reasoningPart(index: number, partIndex: number, length: number): MessagePart {
return {
id: id(`prt_reasoning_${partIndex}`, index),
type: "reasoning",
text: lorem(index * 19 + partIndex, length),
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 500 },
}
}
function toolPart(
index: number,
partIndex: number,
tool: string,
input: Record<string, unknown>,
outputLength = 160,
): MessagePart {
const metadata =
tool === "apply_patch"
? { files: [patchFile(index, "update"), patchFile(index + 1, index % 2 === 0 ? "add" : "delete")] }
: tool === "edit" || tool === "write"
? {
filediff: fileDiff(String(input.filePath ?? `src/generated/file-${index}.ts`), index),
diff: patch(index, outputLength),
preview: patch(index + 1, 420),
}
: tool === "question"
? { answers: [["Proceed"], ["Keep sample output"]] }
: {}
return {
id: id(`prt_tool_${tool}_${partIndex}`, index),
type: "tool",
callID: id("call", index * 10 + partIndex),
tool,
state: {
status: "completed",
input,
output: lorem(index * 23 + partIndex, outputLength),
title: tool === "bash" ? "Verify generated output" : input.filePath || input.path || input.pattern || "completed",
metadata,
time: { start: 1700000000000 + index * 10_000, end: 1700000000000 + index * 10_000 + 400 },
},
}
}
function patchFile(seed: number, type: "add" | "update" | "delete") {
return {
filePath: `src/generated/patch-${seed}.ts`,
relativePath: `src/generated/patch-${seed}.ts`,
type,
additions: (seed % 7) + 1,
deletions: type === "add" ? 0 : seed % 4,
patch: patch(seed, 520),
before: type === "add" ? undefined : code(seed, 18),
after: type === "delete" ? undefined : code(seed + 1, 24),
}
}
function fileDiff(file: string, seed: number) {
return {
file,
additions: (seed % 9) + 1,
deletions: seed % 4,
before: code(seed, 32),
after: code(seed + 1, 38),
}
}
function patch(seed: number, length: number) {
return `diff --git a/src/generated/file-${seed}.ts b/src/generated/file-${seed}.ts\n+${lorem(seed, length).replace(/\n/g, "\n+")}`
}
function code(seed: number, lines: number) {
return Array.from({ length: lines }, (_, index) => `export const value${index} = "${lorem(seed + index, 32)}"`).join(
"\n",
)
}
function turn(index: number): Message[] {
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
const parts = [
...(index % 5 === 0 ? [reasoningPart(index, 0, 420)] : []),
...(index % 3 === 0
? [
toolPart(index, 0, "read", { filePath: `src/generated/file-${index}.ts`, offset: 0, limit: 80 }, 220),
toolPart(index, 5, "glob", { path: directory, pattern: `**/*sample-${index}*.ts` }, 140),
toolPart(index, 1, "grep", { path: directory, pattern: `sample-${index}`, include: "*.ts" }, 180),
toolPart(index, 6, "list", { path: `src/generated/${index}` }, 120),
]
: []),
textPart(index, 2, 160 + (index % 6) * 90),
...(index % 4 === 0 ? [toolPart(index, 3, "edit", { filePath: `src/generated/file-${index}.ts` }, 700)] : []),
...(index % 6 === 0
? [toolPart(index, 7, "write", { filePath: `src/generated/write-${index}.ts`, content: code(index, 28) }, 560)]
: []),
...(index % 8 === 0
? [toolPart(index, 8, "apply_patch", { files: [`src/generated/patch-${index}.ts`] }, 620)]
: []),
...(index % 7 === 0
? [toolPart(index, 4, "bash", { command: "bun typecheck", description: "Verify generated output" }, 620)]
: []),
...(index % 10 === 0 ? [toolPart(index, 9, "webfetch", { url: "https://example.com/docs/sample" }, 120)] : []),
...(index % 11 === 0 ? [toolPart(index, 10, "websearch", { query: "sample movement notes" }, 240)] : []),
...(index % 13 === 0
? [
toolPart(
index,
11,
"question",
{ questions: [{ question: "Use generated fixture?" }, { question: "Keep same row shape?" }] },
120,
),
]
: []),
...(index % 17 === 0
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
: []),
]
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "smoke-project",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [
{
id: sourceID,
slug: "source",
projectID,
directory,
title: "Uncommitted changes inquiry",
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: targetID,
slug: "target",
projectID,
directory,
title: "Example Game: sample jump movement & sample physics analysis",
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
targetMessageIDs: targetMessages
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const end = before
? Math.max(
0,
messages.findIndex((message) => message.info.id === before),
)
: messages.length
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
}
}
@@ -1,428 +0,0 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
const forbiddenText = ["Load details", "Show earlier steps"]
type SmokeState = {
ids: string[]
visibleIds: string[]
messageIds: string[]
visibleMessageIds: string[]
topVisibleId?: string
signature: string
scrollTop: number
scrollHeight: number
clientHeight: number
errorToasts: string[]
forbiddenText: string[]
}
type SmokeWindow = Window & {
__timelineSmokeState?: () => SmokeState
__timelineSmokeErrorToasts?: string[]
__timelineSmokeForbiddenText?: string[]
}
test.describe("smoke: session timeline", () => {
test.setTimeout(240_000)
test("renders seeded timeline in order while paging through history", async ({ page }) => {
const errors = trackPageErrors(page)
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
})
await configureSmokePage(page, fixture.directory)
await selectHomeProject(page, fixture.project.name)
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
await expectCanScrollToStart(page, expectedPartIDs, expectedMessageIDs, errors)
})
})
async function configureSmokePage(page: Page, directory: string) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: {
editToolPartsExpanded: true,
shellToolPartsExpanded: true,
showReasoningSummaries: true,
showSessionProgressBar: true,
},
}),
)
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: {
local: [{ worktree: directory, expanded: true }],
},
lastProject: {
local: directory,
},
}),
)
}, directory)
await page.addInitScript(() => {
const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = []
const partSelector = "[data-timeline-part-id], [data-timeline-part-ids]"
const idsOf = (el: HTMLElement) =>
[el.dataset.timelinePartId, ...(el.dataset.timelinePartIds?.split(",") ?? [])].filter((id): id is string => !!id)
smoke.__timelineSmokeState = () => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
return {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
const ids: string[] = []
const visibleIds: string[] = []
const scrollerRect = scroller.getBoundingClientRect()
let topVisibleId: string | undefined
for (const el of scroller.querySelectorAll<HTMLElement>(partSelector)) {
const next = idsOf(el)
ids.push(...next)
const rect = el.getBoundingClientRect()
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) {
if (!topVisibleId) topVisibleId = next[0]
visibleIds.push(...next)
}
}
const messageIds: string[] = []
const visibleMessageIds: string[] = []
const rows = [...scroller.querySelectorAll<HTMLElement>("[data-message-id]")].map((el) => {
const rect = el.getBoundingClientRect()
const id = el.dataset.messageId
if (id) {
messageIds.push(id)
if (rect.bottom >= scrollerRect.top && rect.top <= scrollerRect.bottom) visibleMessageIds.push(id)
}
return {
id,
top: Math.round(rect.top),
bottom: Math.round(rect.bottom),
}
})
const signature = JSON.stringify({
top: Math.round(scroller.scrollTop),
height: Math.round(scroller.scrollHeight),
rows,
ids,
})
return {
ids,
visibleIds,
messageIds,
visibleMessageIds,
topVisibleId,
signature,
scrollTop: Math.round(scroller.scrollTop),
scrollHeight: Math.round(scroller.scrollHeight),
clientHeight: Math.round(scroller.clientHeight),
errorToasts: smoke.__timelineSmokeErrorToasts ?? [],
forbiddenText: smoke.__timelineSmokeForbiddenText ?? [],
}
}
let recordFrame: number | undefined
const record = () => {
for (const toast of document.querySelectorAll<HTMLElement>('[data-component="toast"][data-variant="error"]')) {
const text = toast.textContent?.trim()
if (text && !smoke.__timelineSmokeErrorToasts!.includes(text)) smoke.__timelineSmokeErrorToasts!.push(text)
}
const text = document.body?.textContent ?? ""
for (const value of ["Load details", "Show earlier steps"]) {
if (text.includes(value) && !smoke.__timelineSmokeForbiddenText!.includes(value)) {
smoke.__timelineSmokeForbiddenText!.push(value)
}
}
}
const start = () => {
const root = document.documentElement ?? document.body
if (!root) return
new MutationObserver(() => {
if (recordFrame) return
recordFrame = requestAnimationFrame(() => {
recordFrame = undefined
record()
})
}).observe(root, { childList: true, subtree: true })
record()
}
if (document.documentElement ?? document.body) start()
else document.addEventListener("DOMContentLoaded", start, { once: true })
})
}
async function expectCanScrollToStart(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await pointAtTimeline(page)
const seenParts = new Set<string>()
const seenMessages = new Set<string>()
const samples: TraversalSample[] = []
let current = await timelineState(page)
let unchangedAtTop = 0
for (let attempt = 0; attempt < 600; attempt++) {
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectNoSmokeErrors(errors, current.errorToasts, current.forbiddenText)
expectOrderedIDs(expectedPartIDs, current.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, current.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(current.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(current.visibleMessageIds), "visible message")
if (
current.scrollTop <= 1 &&
seenParts.size === expectedPartIDs.length &&
seenMessages.size === expectedMessageIDs.length
) {
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
return
}
const before = current
const changed = await scrollTimelineUp(page, current)
current = await timelineState(page)
if (!changed && current.signature === before.signature && current.scrollTop <= 1) unchangedAtTop++
else unchangedAtTop = 0
if (unchangedAtTop >= 2) break
}
collectSeen(current, seenParts, seenMessages)
samples.push(sampleTraversal(current, seenParts.size, seenMessages.size))
expectCompleteScroll(current, expectedPartIDs, expectedMessageIDs, seenParts, seenMessages, samples)
}
async function timelineState(page: Page) {
return page.evaluate(
() =>
(window as SmokeWindow).__timelineSmokeState?.() ?? {
ids: [],
visibleIds: [],
messageIds: [],
visibleMessageIds: [],
topVisibleId: undefined,
signature: "",
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
errorToasts: [],
forbiddenText: [],
},
)
}
function timelineScroller(page: Page) {
return page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
}
async function pointAtTimeline(page: Page) {
const box = await timelineScroller(page).boundingBox()
if (!box) throw new Error("Timeline scroller is not visible")
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
async function scrollTimelineUp(page: Page, before: SmokeState) {
return page.evaluate(
(prev) =>
new Promise<boolean>((resolve) => {
const scroller = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((el) =>
el.querySelector("[data-timeline-row], [data-session-title]"),
)
if (!scroller) {
resolve(false)
return
}
scroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, cancelable: true, deltaY: -1, deltaMode: 0 }))
scroller.scrollTop = Math.max(0, scroller.scrollTop - Math.max(80, Math.round(scroller.clientHeight * 0.45)))
const read = () => (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
let frames = 0
let stableFrames = 0
let last = ""
let changed = false
const check = () => {
const current = read()
if (current !== prev) changed = true
if (current === last) stableFrames++
else {
stableFrames = 0
last = current
}
if (changed && stableFrames >= 2) {
resolve(true)
return
}
frames++
if (frames >= 30) {
resolve(changed)
return
}
requestAnimationFrame(check)
}
requestAnimationFrame(check)
}),
before.signature,
)
}
function expectOrderedIDs(expected: string[], actual: string[], label: string) {
expect(actual.length, `${label} ids should not be empty`).toBeGreaterThan(0)
const actualSet = new Set(actual)
expect(actual, `${label} ids`).toEqual(expected.filter((id) => actualSet.has(id)))
}
function unique(values: string[]) {
return values.filter((value, index) => values.indexOf(value) === index)
}
function collectSeen(state: SmokeState, seenParts: Set<string>, seenMessages: Set<string>) {
for (const id of state.ids) seenParts.add(id)
for (const id of state.visibleIds) seenParts.add(id)
for (const id of state.messageIds) seenMessages.add(id)
for (const id of state.visibleMessageIds) seenMessages.add(id)
}
type TraversalSample = ReturnType<typeof sampleTraversal>
function sampleTraversal(state: SmokeState, seenParts: number, seenMessages: number) {
return {
seenParts,
seenMessages,
mounted: state.ids.length,
visible: state.visibleIds.length,
mountedMessages: unique(state.messageIds).length,
visibleMessages: unique(state.visibleMessageIds).length,
top: state.scrollTop,
height: state.scrollHeight,
first: state.ids[0],
last: state.ids.at(-1),
topVisible: state.topVisibleId,
visibleFirst: state.visibleIds[0],
visibleLast: state.visibleIds.at(-1),
}
}
function sampleSummary(samples: TraversalSample[]) {
return samples
.filter((_, index) => index % Math.max(1, Math.floor(samples.length / 8)) === 0 || index === samples.length - 1)
.map(
(sample, index) =>
`${index}: seenParts=${sample.seenParts} seenMessages=${sample.seenMessages} mounted=${sample.mounted}/${sample.mountedMessages} visible=${sample.visible}/${sample.visibleMessages} top=${sample.top}/${sample.height} first=${sample.first} last=${sample.last} topVisible=${sample.topVisible} visible=${sample.visibleFirst}..${sample.visibleLast}`,
)
.join("\n")
}
async function waitForTimelineStable(page: Page) {
await page.waitForFunction(
() =>
new Promise<boolean>((resolve) => {
requestAnimationFrame(() => {
const a = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() => {
const b = (window as SmokeWindow).__timelineSmokeState?.().signature ?? ""
requestAnimationFrame(() =>
resolve(!!a && a === b && b === ((window as SmokeWindow).__timelineSmokeState?.().signature ?? "")),
)
})
})
}),
)
}
async function expectSessionTimelineReady(
page: Page,
expectedPartIDs: string[],
expectedMessageIDs: string[],
errors: string[],
) {
await waitForTimelineStable(page)
for (const text of forbiddenText) await expect(page.getByText(text)).toHaveCount(0)
const currentState = await timelineState(page)
expectNoSmokeErrors(errors, currentState.errorToasts, currentState.forbiddenText)
expectOrderedIDs(expectedPartIDs, currentState.ids, "mounted part")
expectOrderedIDs(expectedPartIDs, currentState.visibleIds, "visible part")
expectOrderedIDs(expectedMessageIDs, unique(currentState.messageIds), "mounted message")
expectOrderedIDs(expectedMessageIDs, unique(currentState.visibleMessageIds), "visible message")
}
function expectCompleteScroll(
state: SmokeState,
expectedPartIDs: string[],
expectedMessageIDs: string[],
seenParts: Set<string>,
seenMessages: Set<string>,
samples: TraversalSample[],
) {
expect(state.scrollTop, `timeline should reach the start\n${sampleSummary(samples)}`).toBeLessThanOrEqual(1)
expect(
expectedPartIDs.filter((id) => !seenParts.has(id)),
`missing visible timeline parts\n${sampleSummary(samples)}`,
).toEqual([])
expect(
expectedMessageIDs.filter((id) => !seenMessages.has(id)),
`missing visible messages\n${sampleSummary(samples)}`,
).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331)
}
async function selectHomeProject(page: Page, projectName: string) {
await page.goto("/")
await page
.locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") })
.click()
await expect(page).toHaveURL(/\/$/)
}
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function expectSessionReady(page: Page) {
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
}
+11
View File
@@ -0,0 +1,11 @@
import { test } from "@playwright/test"
test(
"test something cool",
{
annotation: { type: "todo" },
},
async () => {
test.fixme()
},
)
-18
View File
@@ -1,18 +0,0 @@
import { expect, type Page } from "@playwright/test"
export function trackPageErrors(page: Page) {
const errors: string[] = []
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text())
})
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
return errors
}
export function expectNoSmokeErrors(consoleErrors: string[], toastErrors: string[], forbiddenText: string[]) {
expect({ consoleErrors, toastErrors, forbiddenText }).toEqual({
consoleErrors: [],
toastErrors: [],
forbiddenText: [],
})
}
-92
View File
@@ -1,92 +0,0 @@
import type { Page, Route } from "@playwright/test"
const emptyList = new Set([
"/skill",
"/command",
"/lsp",
"/formatter",
"/permission",
"/question",
"/vcs/status",
"/vcs/diff",
])
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"])
export interface MockServerConfig {
provider: unknown
directory: string
project: unknown
sessions: ({ id: string } & Record<string, unknown>)[]
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
events?: () => unknown[]
}
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const staticRoutes: Record<string, unknown> = {
"/provider": config.provider,
"/path": {
state: config.directory,
config: config.directory,
worktree: config.directory,
directory: config.directory,
home: "C:/OpenCode",
},
"/project": [config.project],
"/project/current": config.project,
"/agent": [{ name: "build", mode: "primary" }],
"/vcs": { branch: "main", default_branch: "main" },
"/session": config.sessions,
}
await page.route("**/*", async (route) => {
const url = new URL(route.request().url())
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
if (url.port !== targetPort) return route.fallback()
const path = url.pathname
if (path === "/global/event" || path === "/event") return sse(route, config.events?.())
if (path === "/global/health") return json(route, { healthy: true })
if (emptyObject.has(path)) return json(route, {})
if (emptyList.has(path)) return json(route, [])
if (path in staticRoutes) return json(route, staticRoutes[path])
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
if (sessionMatch) {
const session = config.sessions.find((s) => s.id === sessionMatch[1])
return json(route, session ?? {})
}
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(path)) return json(route, [])
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
if (messagesMatch) {
const limit = Number(url.searchParams.get("limit") ?? 80)
const before = url.searchParams.get("before") ?? undefined
const pageData = config.pageMessages(messagesMatch[1], limit, before)
return json(route, pageData.items, pageData.cursor ? { "x-next-cursor": pageData.cursor } : undefined)
}
return json(route, {})
})
}
function json(route: Route, body: unknown, headers?: Record<string, string>) {
return route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": "*",
"access-control-expose-headers": "x-next-cursor",
...headers,
},
body: JSON.stringify(body ?? null),
})
}
function sse(route: Route, events?: unknown[]) {
return route.fulfill({
status: 200,
contentType: "text/event-stream",
body: events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n",
})
}
+3 -5
View File
@@ -1,11 +1,10 @@
{
"name": "@opencode-ai/app",
"version": "1.15.11",
"version": "1.15.4",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
@@ -42,10 +41,10 @@
},
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@sentry/solid": "catalog:",
"@opencode-ai/core": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
@@ -54,7 +53,6 @@
"@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scheduled": "1.5.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
Binary file not shown.
+41 -54
View File
@@ -14,7 +14,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -31,8 +30,8 @@ import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
@@ -41,26 +40,30 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
const SessionRoute = Object.assign(
() => (
<SessionProviders>
<Session />
</SessionProviders>
),
{ preload: Session.preload },
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
</SessionProviders>
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
@@ -75,7 +78,6 @@ declare global {
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
}
@@ -93,26 +95,9 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
@@ -315,29 +300,31 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServersProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<ServerSDKProvider>
<ServerSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</ServerSyncProvider>
</ServerSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
</ServersProvider>
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<QueryProvider>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Dynamic>
</GlobalSyncProvider>
</GlobalSDKProvider>
</QueryProvider>
</ServerKey>
</ConnectionGate>
</ServerProvider>
)
}
@@ -12,15 +12,15 @@ import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const providers = useProviders()
@@ -41,7 +41,9 @@ export function DialogConnectProvider(props: { provider: string }) {
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
() =>
providers.all().find((x) => x.id === props.provider) ??
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
)
const fallback = createMemo<ProviderAuthMethod[]>(() => [
{
@@ -52,16 +54,16 @@ export function DialogConnectProvider(props: { provider: string }) {
const [auth] = createResource(
() => props.provider,
async () => {
const cached = serverSync.data.provider_auth[props.provider]
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await serverSDK.client.provider.auth()
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
serverSync.set("provider_auth", res.data ?? {})
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
)
const loading = createMemo(() => auth.loading && !serverSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? serverSync.data.provider_auth[props.provider] ?? fallback())
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -158,7 +160,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
dispatch({ type: "auth.pending" })
const start = Date.now()
await serverSDK.client.provider.oauth
await globalSDK.client.provider.oauth
.authorize(
{
providerID: props.provider,
@@ -277,7 +279,6 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">{select()?.message}</div>
<div>
<List
class="px-3"
items={select()?.options ?? []}
key={(x) => x.value}
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
@@ -331,7 +332,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
async function complete() {
await serverSDK.client.global.dispose()
await globalSDK.client.global.dispose()
dialog.close()
showToast({
variant: "success",
@@ -365,7 +366,6 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
<div>
<List
class="px-3"
ref={(ref) => {
listRef = ref
}}
@@ -409,7 +409,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
await serverSDK.client.auth.set({
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
@@ -480,7 +480,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}
setFormStore("error", undefined)
const result = await serverSDK.client.provider.oauth
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -526,14 +526,14 @@ export function DialogConnectProvider(props: { provider: string }) {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
return instructions.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
const result = await serverSDK.client.provider.oauth
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
@@ -9,8 +9,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { batch, For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider"
@@ -21,8 +21,8 @@ type Props = {
export function DialogCustomProvider(props: Props) {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [form, setForm] = createStore<FormState>({
@@ -105,8 +105,8 @@ export function DialogCustomProvider(props: Props) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
batch(() => {
setForm("err", output.err)
@@ -118,11 +118,11 @@ export function DialogCustomProvider(props: Props) {
const saveMutation = useMutation(() => ({
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
const disabledProviders = serverSync.data.config.disabled_providers ?? []
const disabledProviders = globalSync.data.config.disabled_providers ?? []
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
if (result.key) {
await serverSDK.client.auth.set({
await globalSDK.client.auth.set({
providerID: result.providerID,
auth: {
type: "api",
@@ -131,7 +131,7 @@ export function DialogCustomProvider(props: Props) {
})
}
await serverSync.updateConfig({
await globalSync.updateConfig({
provider: { [result.providerID]: result.config },
disabled_providers: nextDisabled,
})
@@ -6,20 +6,20 @@ import { useMutation } from "@tanstack/solid-query"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const language = useLanguage()
const folderName = createMemo(() => getFilename(props.project.worktree))
@@ -78,19 +78,19 @@ export function DialogEditProject(props: { project: LocalProject }) {
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await serverSDK.client.project.update({
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
serverSync.project.icon(props.project.worktree, store.iconOverride || undefined)
globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
serverSync.project.meta(props.project.worktree, {
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
+1 -1
View File
@@ -88,7 +88,7 @@ export const DialogFork: Component = () => {
return (
<Dialog title={language.t("command.session.fork")}>
<List
class="flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.fork.empty")}
key={(x) => x.id}
@@ -39,7 +39,6 @@ export const DialogManageModels: Component = () => {
}
>
<List
class="px-3"
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -6,8 +6,8 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useLanguage } from "@/context/language"
@@ -128,7 +128,7 @@ function uniqueRows(rows: Row[]) {
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useServerSDK>
sdk: ReturnType<typeof useGlobalSDK>
start: () => string | undefined
home: () => string
}) {
@@ -246,8 +246,8 @@ function useDirectorySearch(args: {
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useServerSync()
const sdk = useServerSDK()
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const layout = useLayout()
const dialog = useDialog()
const language = useLanguage()
@@ -324,7 +324,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
@@ -9,8 +9,8 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
@@ -175,7 +175,7 @@ function createFileEntries(props: {
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
serverSDK: ReturnType<typeof useServerSDK>
globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
@@ -207,7 +207,7 @@ function createSessionEntries(props: {
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.serverSDK.client.session
return props.globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
@@ -268,8 +268,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const { params, tabs, view } = useSessionLayout()
const filesOnly = () => props.mode === "files"
const state = { cleanup: undefined as (() => void) | void, committed: false }
@@ -292,21 +292,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => serverSync.data.path.home)
const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = serverSync.child(directory, { bootstrap: false })
const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const { sessions } = createSessionEntries({ workspaces, label, serverSDK, language })
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const items = async (text: string) => {
const query = text.trim()
@@ -386,7 +386,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return (
<Dialog class="pt-3 pb-0 !max-h-[480px]" transition>
<List
class="px-3"
search={{
placeholder: filesOnly()
? language.t("session.header.searchFiles")
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
import { useQueryOptions } from "@/context/server-sync"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
const statusLabels = {
@@ -55,7 +55,6 @@ export const DialogSelectMcp: Component = () => {
description={language.t("dialog.mcp.description", { enabled: enabledCount(), total: totalCount() })}
>
<List
class="px-3"
search={{ placeholder: language.t("common.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.mcp.empty")}
key={(x) => x?.name ?? ""}
@@ -45,7 +45,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="px-3 [&_[data-slot=list-scroll]]:overflow-visible"
class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
items={model.list}
current={model.current()}
@@ -90,8 +90,8 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
<div class="px-2 text-14-medium text-text-base">{language.t("dialog.model.unpaid.addMore.title")}</div>
<div class="w-full">
<List
class="w-full px-3"
key={(p) => p.id}
class="w-full px-0"
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
@@ -37,7 +37,7 @@ const ModelList: Component<{
return (
<List
class={`flex-1 px-3 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true, action: props.action }}
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
@@ -29,14 +29,13 @@ export const DialogSelectProvider: Component = () => {
return (
<Dialog title={language.t("command.provider.connect")} transition>
<List
class="px-3"
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.provider.empty")}
activeIcon="plus-small"
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -121,7 +121,7 @@ function ServerForm(props: ServerFormProps) {
}
return (
<div class="">
<div class="px-5">
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
<TextField
@@ -172,14 +172,6 @@ function ServerForm(props: ServerFormProps) {
}
export function DialogSelectServer() {
return <ServerManagement dialog />
}
export function ServerManagementSettings() {
return <ServerManagement />
}
function ServerManagement(props: { dialog?: boolean }) {
const navigate = useNavigate()
const dialog = useDialog()
const server = useServer()
@@ -359,7 +351,7 @@ function ServerManagement(props: { dialog?: boolean }) {
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
if (props.dialog) dialog.close()
dialog.close()
if (persist && conn.type === "http") {
server.add(conn)
navigate("/")
@@ -510,151 +502,148 @@ function ServerManagement(props: { dialog?: boolean }) {
}
}
const content = () => (
<div class="flex flex-1 min-h-0 flex-col gap-4">
<Show when={!props.dialog && isFormMode()}>
<div class="text-16-medium text-text-strong">{formTitle()}</div>
</Show>
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<List
class="px-3 flex-1 min-h-0 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show>
<div class="shrink-0 pb-5">
return (
<Dialog title={formTitle()}>
<div class="flex flex-1 min-h-0 flex-col gap-2">
<Show
when={isFormMode()}
when={!isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div>
</div>
)
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x.http.url}
onSelect={(x) => {
if (x) void select(x)
}}
divider={true}
class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
>
{(i) => {
const key = ServerConnection.key(i)
return (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
return props.dialog ? <Dialog title={formTitle()}>{content()}</Dialog> : content()
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
)
}}
</List>
</Show>
<div class="shrink-0 px-5 pb-5">
<Show
when={isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
{formBusy()
? language.t("dialog.server.add.checking")
: isAddMode()
? language.t("dialog.server.add.button")
: language.t("common.save")}
</Button>
</Show>
</div>
</div>
</Dialog>
)
}
@@ -8,7 +8,6 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsServers } from "./settings-servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -18,7 +17,7 @@ export const DialogSettings: Component = () => {
<Dialog size="x-large" transition>
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List>
<div class="flex flex-col justify-between h-full w-full gap-4">
<div class="flex flex-col justify-between h-full w-full">
<div class="flex flex-col gap-3 w-full pt-3">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
@@ -32,10 +31,6 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</Tabs.Trigger>
<Tabs.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
</div>
</div>
@@ -66,9 +61,6 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="servers" class="no-scrollbar">
<SettingsServers />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
File diff suppressed because it is too large Load Diff
@@ -162,8 +162,8 @@ beforeAll(async () => {
}),
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => ({
mock.module("@/context/global-sync", () => ({
useGlobalSync: () => ({
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
@@ -5,7 +5,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams } from "@solidjs/router"
import { batch, type Accessor } from "solid-js"
import type { FileSelection } from "@/context/file"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
@@ -38,7 +38,7 @@ export type FollowupDraft = {
type FollowupSendInput = {
client: ReturnType<typeof useSDK>["client"]
serverSync: ReturnType<typeof useServerSync>
globalSync: ReturnType<typeof useGlobalSync>
sync: ReturnType<typeof useSync>
draft: FollowupDraft
messageID?: string
@@ -53,7 +53,7 @@ const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttac
export async function sendFollowupDraft(input: FollowupSendInput) {
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const [, setStore] = input.serverSync.child(input.draft.sessionDirectory)
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
const setBusy = () => {
if (!input.optimisticBusy) return
@@ -205,7 +205,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
@@ -226,8 +226,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync.todo.set(sessionID, [])
const [, setStore] = serverSync.child(sdk.directory)
globalSync.todo.set(sessionID, [])
const [, setStore] = globalSync.child(sdk.directory)
setStore("todo", sessionID, [])
input.onAbort?.()
@@ -273,7 +273,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: Session) => {
const [, setStore] = serverSync.child(dir)
const [, setStore] = globalSync.child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -354,7 +354,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
directory: sessionDirectory,
throwOnError: true,
})
serverSync.child(sessionDirectory)
globalSync.child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@@ -557,7 +557,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
client,
sync,
serverSync,
globalSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
@@ -52,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -3,4 +3,3 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -132,7 +132,7 @@ export function SessionContextTab() {
}),
)
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()]))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
@@ -24,9 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
@@ -155,11 +153,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"
const search = createMemo(() => !isDesktopBeta || settings.general.showSearch())
const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree())
const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal())
const status = createMemo(() => !isDesktopBeta || settings.general.showStatus())
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -233,14 +231,6 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
reviewLabel: language.t("command.review.toggle"),
reviewKeybind: command.keybind("review.toggle"),
reviewOpened: view().reviewPanel.opened(),
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@@ -321,235 +311,193 @@ export function SessionHeader() {
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<Show
when={isDesktopV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<Show when={term()}>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
</Show>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<Show when={tree()}>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
</div>
</div>
</Portal>
)}
</Show>
</>
)
}
type SessionHeaderV2ActionsState = {
statusVisible: boolean
statusLabel: string
reviewLabel: string
reviewKeybind: string
reviewOpened: boolean
onReviewToggle: () => void
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
return (
<div class="flex items-center gap-0">
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 />
</Tooltip>
</Show>
<TooltipKeybind title={props.state.reviewLabel} keybind={props.state.reviewKeybind}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
state={props.state.reviewOpened ? "pressed" : undefined}
onClick={props.state.onReviewToggle}
aria-label={props.state.reviewLabel}
aria-expanded={props.state.reviewOpened}
aria-controls="review-panel"
icon={<IconV2 name="sidebar-right" />}
/>
</TooltipKeybind>
</div>
)
}
@@ -1,15 +0,0 @@
import type { JSX } from "solid-js"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
export function NewSessionDesignView(props: { children: JSX.Element }) {
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">{props.children}</div>
</div>
</div>
</div>
)
}
@@ -12,8 +12,8 @@ import { useParams } from "@solidjs/router"
import { useLanguage } from "@/context/language"
import { usePermission } from "@/context/permission"
import { usePlatform, type DisplayBackend } from "@/context/platform"
import { useServerSync } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import {
monoDefault,
monoFontFamily,
@@ -175,8 +175,8 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const serverSync = useServerSync()
const globalSdk = useServerSDK()
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const [shells] = createResource(
() =>
@@ -193,22 +193,16 @@ export const SettingsGeneral: Component = () => {
{ initialValue: null as DisplayBackend | null },
)
const [pinchZoom, { mutate: setPinchZoom }] = createResource(
() => (desktop() && platform.getPinchZoomEnabled ? true : false),
() => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false),
{ initialValue: false },
)
onMount(() => {
void theme.loadThemes()
})
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
const currentShell = createMemo(() => serverSync.data.config.shell ?? "")
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
const shellOptions = createMemo<ShellSelectOption[]>(() => {
const list = shells.latest
const current = serverSync.data.config.shell
const current = globalSync.data.config.shell
const nameCounts = new Map<string, number>()
for (const s of list) {
@@ -245,13 +239,6 @@ export const SettingsGeneral: Component = () => {
})
}
const onPinchZoomChange = (checked: boolean) => {
setPinchZoom(checked)
const update = platform.setPinchZoomEnabled?.(checked)
if (!update) return
void update.catch(() => setPinchZoom(!checked))
}
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
@@ -343,7 +330,7 @@ export const SettingsGeneral: Component = () => {
onSelect={(option) => {
if (!option) return
if (option.value === currentShell()) return
serverSync.updateConfig({ shell: option.value })
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"
@@ -399,18 +386,6 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -456,6 +431,18 @@ export const SettingsGeneral: Component = () => {
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showTerminal.title")}
description={language.t("settings.general.row.showTerminal.description")}
>
<div data-action="settings-show-terminal">
<Switch
checked={settings.general.showTerminal()}
onChange={(checked) => settings.general.setShowTerminal(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showStatus.title")}
description={language.t("settings.general.row.showStatus.description")}
@@ -467,18 +454,6 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.showCustomAgents.title")}
description={language.t("settings.general.row.showCustomAgents.description")}
>
<div data-action="settings-show-custom-agents">
<Switch
checked={settings.general.showCustomAgents()}
onChange={(checked) => settings.general.setShowCustomAgents(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -754,45 +729,7 @@ export const SettingsGeneral: Component = () => {
</div>
)
const DisplaySection = () => (
<Show when={desktop()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<SettingsList>
<SettingsRow
title={language.t("settings.general.row.pinchZoom.title")}
description={language.t("settings.general.row.pinchZoom.description")}
>
<div data-action="settings-pinch-zoom">
<Switch checked={pinchZoom.latest} onChange={onPinchZoomChange} />
</div>
</SettingsRow>
<Show when={linux()}>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</Show>
</SettingsList>
</div>
</Show>
)
console.log(import.meta.env)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -812,9 +749,33 @@ export const SettingsGeneral: Component = () => {
<UpdatesSection />
<DisplaySection />
<Show when={linux()}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
<Show when={desktop()}>
<SettingsList>
<SettingsRow
title={
<div class="flex items-center gap-2">
<span>{language.t("settings.general.row.wayland.title")}</span>
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
<span class="text-text-weak">
<Icon name="help" size="small" />
</span>
</Tooltip>
</div>
}
description={language.t("settings.general.row.wayland.description")}
>
<div data-action="settings-wayland">
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
</div>
</SettingsRow>
</SettingsList>
</div>
</Show>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>
<AdvancedSection />
</Show>
</div>
@@ -6,8 +6,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
@@ -30,8 +30,8 @@ const PROVIDER_NOTES = [
export const SettingsProviders: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const providers = useProviders()
const connected = createMemo(() => {
@@ -74,7 +74,7 @@ export const SettingsProviders: Component = () => {
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync.data.config.provider?.[providerID]
const provider = globalSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -82,11 +82,11 @@ export const SettingsProviders: Component = () => {
}
const disableProvider = async (providerID: string, name: string) => {
const before = serverSync.data.config.disabled_providers ?? []
const before = globalSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync.set("config", "disabled_providers", next)
globalSync.set("config", "disabled_providers", next)
await serverSync
await globalSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -97,7 +97,7 @@ export const SettingsProviders: Component = () => {
})
})
.catch((err: unknown) => {
serverSync.set("config", "disabled_providers", before)
globalSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -105,14 +105,14 @@ export const SettingsProviders: Component = () => {
const disconnect = async (providerID: string, name: string) => {
if (isConfigCustom(providerID)) {
await serverSDK.client.auth.remove({ providerID }).catch(() => undefined)
await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSDK.client.auth
await globalSDK.client.auth
.remove({ providerID })
.then(async () => {
await serverSDK.client.global.dispose()
await globalSDK.client.global.dispose()
showToast({
variant: "success",
icon: "circle-check",
@@ -1,21 +0,0 @@
import { type Component } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerManagementSettings } from "./dialog-select-server"
export const SettingsServers: Component = () => {
const language = useLanguage()
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">{language.t("status.popover.tab.servers")}</h2>
</div>
</div>
<div class="flex flex-col flex-1 min-h-0 max-w-[720px]">
<ServerManagementSettings />
</div>
</div>
)
}
@@ -7,17 +7,16 @@ import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/server-sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
import { useServers } from "@/context/servers"
const pollMs = 10_000
@@ -55,6 +54,40 @@ const listServersByHealth = (
})
}
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (conn) => {
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
@@ -133,162 +166,8 @@ const useMcpToggleMutation = () => {
}))
}
type ServerStatusState = {
servers: () => ServerStatusItem[]
defaultKey: () => ServerConnection.Key | undefined
ariaLabel: string
serversLabel: string
defaultLabel: string
manageLabel: string
onManage: () => void
}
type ServerStatusItem = {
key: ServerConnection.Key
conn: ServerConnection.Any
health?: ServerHealth
blocked: boolean
active: boolean
onSelect: () => void
}
export function StatusPopoverServerBody() {
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const serverItems = createMemo(() =>
sortedServers().map((conn) => {
const key = ServerConnection.key(conn)
return {
key,
conn,
health: servers.health[key],
blocked: servers.health[key]?.healthy === false,
active: !!server.current && key === ServerConnection.key(server.current),
onSelect: () => {
navigate("/")
queueMicrotask(() => server.setActive(key))
},
}
}),
)
return (
<ServerStatusPopoverView
state={{
servers: serverItems,
defaultKey: defaultServer.key,
ariaLabel: language.t("status.popover.ariaLabel"),
serversLabel: language.t("status.popover.tab.servers"),
defaultLabel: language.t("common.default"),
manageLabel: language.t("status.popover.action.manageServers"),
onManage: () => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
},
}}
/>
)
}
function ServerStatusPopoverView(props: { state: ServerStatusState }) {
return (
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs
aria-label={props.state.ariaLabel}
class="tabs bg-background-strong rounded-xl overflow-hidden"
data-component="tabs"
data-active="servers"
defaultValue="servers"
variant="alt"
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
{props.state.serversLabel}
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="servers">
<ServerStatusList state={props.state} />
</Tabs.Content>
</Tabs>
</div>
)
}
function ServerStatusList(props: { state: ServerStatusState }) {
return (
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={props.state.servers()}>
{(item) => {
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
classList={{
"hover:bg-surface-raised-base-hover": !item.blocked,
"cursor-not-allowed": item.blocked,
}}
aria-disabled={item.blocked}
onClick={() => {
if (item.blocked) return
item.onSelect()
}}
>
<ServerHealthIndicator health={item.health} />
<ServerRow
conn={item.conn}
dimmed={item.blocked}
status={item.health}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={item.key === props.state.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{props.state.defaultLabel}
</span>
</Show>
}
>
<div class="flex-1" />
<Show when={item.active}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
</button>
)
}}
</For>
<Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
{props.state.manageLabel}
</Button>
</div>
</div>
)
}
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const servers = useServers()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
@@ -313,7 +192,15 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => listServersByHealth(servers.list(), server.key, servers.health))
const servers = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
})
const health = useServerHealth(servers, props.shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
@@ -339,7 +226,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{servers.list().length > 0 ? `${servers.list().length} ` : ""}
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -362,7 +249,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => servers.health[key]?.healthy === false
const blocked = () => health[key]?.healthy === false
return (
<button
type="button"
@@ -378,11 +265,11 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
queueMicrotask(() => server.setActive(key))
}}
>
<ServerHealthIndicator health={servers.health[key]} />
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
dimmed={blocked()}
status={servers.health[key]}
status={health[key]}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
+7 -144
View File
@@ -1,24 +1,19 @@
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useServers } from "@/context/servers"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
const ServerBody = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverServerBody })))
export function StatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const ready = createMemo(() => servers.health[server.key]?.healthy === false || sync.data.mcp_ready)
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
@@ -26,8 +21,7 @@ export function StatusPopover() {
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const serverHealthy = () => servers.health[server.key]?.healthy === true
const healthy = createMemo(() => servers.health[server.key]?.healthy === true && !mcpIssue())
const healthy = createMemo(() => server.healthy() === true && !mcpIssue())
return (
<Popover
@@ -49,9 +43,10 @@ export function StatusPopover() {
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"bg-icon-success-base": ready() && healthy(),
"bg-icon-warning-base": ready() && serverHealthy() && mcpIssue() === "warning",
"bg-icon-critical-base": serverHealthy() || (ready() && serverHealthy() && mcpIssue() === "critical"),
"bg-border-weak-base": serverHealthy() || !ready(),
"bg-icon-warning-base": ready() && server.healthy() === true && mcpIssue() === "warning",
"bg-icon-critical-base":
server.healthy() === false || (ready() && server.healthy() === true && mcpIssue() === "critical"),
"bg-border-weak-base": server.healthy() === undefined || !ready(),
}}
/>
</div>
@@ -73,135 +68,3 @@ export function StatusPopover() {
</Popover>
)
}
export function StatusPopoverV2(props: { scope?: "server" }) {
if (props.scope === "server") return <ServerStatusPopover />
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || sync.data.mcp_ready)
const mcpIssue = createMemo(() => {
const mcp = Object.values(sync.data.mcp ?? {})
const failed = mcp.some((item) => item.status === "failed" || item.status === "needs_client_registration")
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: ready(),
healthy: healthy(),
serverHealth: serverHealth(),
issue: mcpIssue(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<Body shown={shown} />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
function ServerStatusPopover() {
const language = useLanguage()
const server = useServer()
const servers = useServers()
const [shown, setShown] = createSignal(false)
const serverHealth = () => servers.health[server.key]?.healthy
const state = createMemo<StatusPopoverState>(() => ({
shown: shown(),
ready: serverHealth() !== undefined,
healthy: serverHealth() === true,
serverHealth: serverHealth(),
label: language.t("status.popover.trigger"),
onOpenChange: setShown,
body: () => (
<StatusPopoverBody shown={shown()}>
<ServerBody />
</StatusPopoverBody>
),
}))
return <StatusPopoverView state={state()} />
}
type StatusPopoverState = {
shown: boolean
ready: boolean
healthy: boolean
serverHealth: boolean | undefined
issue?: "critical" | "warning"
label: string
onOpenChange: (value: boolean) => void
body: () => JSX.Element
}
function StatusPopoverBody(props: { shown: boolean; children: JSX.Element }) {
return (
<Show when={props.shown}>
<Suspense
fallback={<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />}
>
{props.children}
</Suspense>
</Show>
)
}
function StatusPopoverView(props: { state: StatusPopoverState }) {
const statusDotClass = () => ({
"absolute rounded-full": true,
"bg-icon-success-base": props.state.ready && props.state.healthy,
"bg-icon-warning-base": props.state.ready && props.state.serverHealth === true && props.state.issue === "warning",
"bg-icon-critical-base":
props.state.serverHealth === false ||
(props.state.ready && props.state.serverHealth === true && props.state.issue === "critical"),
"bg-border-weak-base": props.state.serverHealth === undefined || !props.state.ready,
})
const popoverProps = {
class:
"[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl",
gutter: 4,
placement: "bottom-end" as const,
shift: -168,
}
return (
<Popover
open={props.state.shown}
onOpenChange={props.state.onOpenChange}
triggerAs={IconButtonV2}
triggerProps={{
variant: "ghost-muted",
size: "large",
class: "!w-9 shrink-0",
state: props.state.shown ? "pressed" : undefined,
"aria-label": props.state.label,
}}
trigger={
<div class="relative size-4">
<IconV2 name={props.state.shown ? "status-active" : "status"} />
<div
classList={statusDotClass()}
class="-top-1 -right-1 size-2 border border-[var(--v2-background-bg-deep)]"
/>
</div>
}
{...popoverProps}
>
{props.state.body()}
</Popover>
)
}
@@ -1,28 +0,0 @@
import { describe, expect, test } from "bun:test"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
describe("titlebar session events", () => {
test("reads valid removed session tab details", () => {
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: ["ses_1", "ses_2", 1] },
}),
),
).toEqual({
directory: "/tmp/project",
sessionIDs: ["ses_1", "ses_2"],
})
})
test("ignores invalid removed session tab details", () => {
expect(readSessionTabsRemovedDetail(new Event(SESSION_TABS_REMOVED_EVENT))).toBeUndefined()
expect(
readSessionTabsRemovedDetail(
new CustomEvent(SESSION_TABS_REMOVED_EVENT, {
detail: { directory: "/tmp/project", sessionIDs: [] },
}),
),
).toBeUndefined()
})
})
@@ -1,29 +0,0 @@
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
directory: string
sessionIDs: string[]
}
export function notifySessionTabsRemoved(input: SessionTabsRemovedDetail) {
window.dispatchEvent(new CustomEvent(SESSION_TABS_REMOVED_EVENT, { detail: input }))
}
export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDetail | undefined {
if (!(event instanceof CustomEvent)) return undefined
const detail: unknown = event.detail
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
directory: detail.directory,
sessionIDs,
}
}
+154 -651
View File
@@ -1,34 +1,18 @@
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useServerSync } from "@/context/server-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { StatusPopoverV2 } from "@/components/status-popover"
import {
readSessionTabsRemovedDetail,
SESSION_TABS_REMOVED_EVENT,
type SessionTabsRemovedDetail,
} from "@/components/titlebar-session-events"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -51,20 +35,11 @@ type TauriApi = {
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const titlebarHeight = 40
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = {
version: () => string | undefined
installing: () => boolean
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
@@ -74,20 +49,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -119,23 +90,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
visible: version !== undefined,
installing: props.update?.installing() ?? false,
label: "Update",
ariaLabel: language.t("toast.update.action.installRestart"),
title: version ? `Update ${version}` : undefined,
onInstall: () => props.update?.install(),
}
})
const v2RightState = createMemo<TitlebarV2RightState>(() => ({
update: updateState(),
statusVisible: !params.dir && settings.general.showStatus(),
statusLabel: language.t("status.popover.trigger"),
}))
const nav = createMemo(() => import.meta.env.VITE_OPENCODE_CHANNEL !== "beta" || settings.general.showNavigation())
const back = () => {
const next = backPath(history)
@@ -220,614 +175,162 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}}
class="h-10 shrink-0 bg-background-base relative overflow-hidden"
style={{ "min-height": minHeight() }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
<Switch>
<Match when={useV2Titlebar()}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const newSessionHref = () => {
if (params.dir) return `/${params.dir}/session`
const project = layout.projects.list()[0]
if (!project) return "/"
return `/${base64Encode(project.worktree)}/session`
}
type Tab = { dir: string; sessionId: string; href: string }
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined
const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1
const removedCurrent =
currentIndex !== -1 &&
tabs[currentIndex]?.dir === input.directory &&
sessionIDs.has(tabs[currentIndex]?.sessionId ?? "")
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab) continue
if (tab.dir !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => {
const detail = readSessionTabsRemovedDetail(event)
if (!detail) return
tabsStoreActions.removeSessions(detail)
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
href: makeSessionHref(params.dir, params.id),
})
})
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const currentSessionTab = () => {
if (!params.dir || !params.id) return
const href = makeSessionHref(params.dir, params.id)
return tabsStore.find((tab) => tab.href === href)
}
const closeCurrentSessionTab = () => {
const tab = currentSessionTab()
if (!tab) return false
tabsStoreActions.removeTab(tab.href)
return true
}
const closeNewSessionTab = () => {
if (!(params.dir && !params.id)) return false
const last = tabsStore[tabsStore.length - 1]
if (last) navigate(last.href)
else navigate("/")
return true
}
makeEventListener(
document,
"keydown",
(event) => {
if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
if (event.key.toLowerCase() !== "w") return
if (!(closeCurrentSessionTab() || closeNewSessionTab())) return
event.preventDefault()
event.stopPropagation()
},
{ capture: true },
)
command.register(() => {
const commands = [
{
id: `tab.prev`,
category: "tab",
title: "",
keybind: `mod+option+ArrowLeft`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
if (index === -1) return
index -= 1
if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index]
if (next) navigate(next.href)
},
},
{
id: `tab.next`,
category: "tab",
title: "",
keybind: `mod+option+ArrowRight`,
hidden: true,
onSelect: () => {
let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href)
if (index === -1) return
index += 1
if (index === tabsStore.length) index = 0
const next = tabsStore[index]
if (next) navigate(next.href)
},
},
...Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
return {
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
disabled: layout.projects.list().length <= index,
hidden: true,
onSelect: () => {
const tab = tabsStore[index]
if (tab) navigate(tab.href)
},
}
}),
]
return commands
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = serverSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return (
<div
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
}}
>
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show>
<IconButtonV2
variant="ghost-muted"
size="large"
as="a"
href="/"
class="!w-9"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
href={tab.href}
title={tab.info.title}
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
/>
</>
)}
</For>
</div>
<Show
when={creating() && params.dir}
fallback={
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
as="a"
href={newSessionHref()}
aria-label={language.t("command.session.new")}
/>
}
>
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
/>
</Show>
<div class="min-w-0 flex-1" />
</div>
<TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
)
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div>
</div>
</Match>
</Switch>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</header>
)
}
type TitlebarUpdatePillState = {
visible: boolean
installing: boolean
label: string
ariaLabel: string
title?: string
onInstall: () => void
}
type TitlebarV2RightState = {
update: TitlebarUpdatePillState
statusVisible: boolean
statusLabel: string
}
function TitlebarV2Right(props: { state: TitlebarV2RightState }) {
return (
<div class="flex shrink-0 items-center justify-end gap-0">
<TitlebarUpdatePill state={props.state.update} />
<Show when={props.state.statusVisible}>
<Tooltip placement="bottom" value={props.state.statusLabel}>
<StatusPopoverV2 scope="server" />
</Tooltip>
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
</div>
)
}
function TitlebarUpdatePill(props: { state: TitlebarUpdatePillState }) {
return (
<Show when={props.state.visible}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-layer-03)] px-2.5 text-[11px] font-[530] leading-4 tracking-[0.05px] text-[var(--v2-text-text-base)] disabled:opacity-60"
onClick={props.state.onInstall}
disabled={props.state.installing}
aria-label={props.state.ariaLabel}
title={props.state.title}
>
{props.state.label}
</button>
</Show>
)
}
function TabNavItem(props: {
href: string
title: string
project?: LocalProject
directory: string
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
>
<ProjectTabAvatar project={props.project} directory={props.directory} />
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div>
)
}
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
return (
<AvatarV2
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project?.icon?.color)}
class="size-4 rounded"
/>
)
}
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
return (
<div class="group relative flex h-7 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate leading-5">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</>
)
}
@@ -1,131 +0,0 @@
import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
variant?: "legacy" | "v2"
}) {
let lastFocused: HTMLElement | undefined
const rememberFocus = () => {
const active = document.activeElement
lastFocused = active instanceof HTMLElement ? active : undefined
}
const commandDisabled = (id: string) => {
const option = props.command.options.find((option) => option.id === id)
if (!option) return true
return option.disabled ?? false
}
const runCommand = (id: string) => {
if (commandDisabled(id)) return
props.command.trigger(id)
}
const runAction = (action: DesktopMenuAction) => {
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
void props.platform.runDesktopMenuAction?.(action)
}
const runEntry = (entry: DesktopMenuEntry) => {
if (entry.type === "separator") return
if (entry.command) {
runCommand(entry.command)
return
}
if (entry.action) {
runAction(entry.action)
return
}
if (entry.href) props.platform.openLink(entry.href)
}
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? (
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<DropdownMenu.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<span data-slot="dropdown-menu-item-label">{props.label}</span>
<span data-slot="desktop-app-menu-chevron">
<Icon name="chevron-right" size="small" />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)
}
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
return (
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
<Show when={props.keybind}>
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
</Show>
</DropdownMenu.Item>
)
}
-613
View File
@@ -1,613 +0,0 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { createServerSyncContext } from "./server-sync"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
import { useServerSDK } from "./server-sdk"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const isNotFound = (error: unknown) =>
error instanceof Error &&
typeof error.cause === "object" &&
error.cause !== null &&
(error.cause as { status?: unknown }).status === 404
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = (directory: string, serverSync: ReturnType<typeof createServerSyncContext>) => {
const serverSDK = useServerSDK()
const client = serverSDK.createClient({ directory, throwOnError: true })
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => serverSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = serverSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = serverSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.catch((error) => {
if (isNotFound(error) && !tracked(input.directory, input.sessionID)) return
throw error
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(serverSync.data.project, store.project, (p) => p.id)
if (match.found) return serverSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID }))
.then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
.catch((error) => {
if (isNotFound(error) && !tracked(directory, sessionID)) return
throw error
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = serverSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
serverSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
serverSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = serverSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = serverSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = serverSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = serverSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
+254
View File
@@ -0,0 +1,254 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { useServer } from "./server"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: () => {
const language = useLanguage()
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.current.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const currentServer = server.current
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.current.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: currentServer.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
return createSdkForServer({
server: s.http,
fetch: platform.fetch,
...opts,
})
},
}
},
})
@@ -1,11 +1,19 @@
import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse, Todo } from "@opencode-ai/sdk/v2/client"
import type {
Config,
OpencodeClient,
Path,
Project,
ProviderAuthResponse,
ProviderListResponse,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { useServerSDK } from "./server-sdk"
import { useGlobalSDK } from "./global-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -28,9 +36,6 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext, NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { createRefCountMap } from "@/utils/refcount"
type GlobalStore = {
ready: boolean
@@ -40,7 +45,7 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
@@ -58,13 +63,13 @@ export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
function makeQueryOptionsApi(globalSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
return {
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
projects: () => loadProjectsQuery(serverSDK()),
globalConfig: () => loadGlobalConfigQuery(globalSDK()),
projects: () => loadProjectsQuery(globalSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
loadProvidersQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? globalSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
@@ -73,11 +78,11 @@ function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: Path
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
export function createServerSyncContext() {
const serverSDK = useServerSDK()
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("ServerSync must be created within owner")
if (!owner) throw new Error("GlobalSync must be created within owner")
const sdkCache = new Map<string, OpencodeClient>()
const booting = new Map<string, Promise<void>>()
@@ -88,7 +93,7 @@ export function createServerSyncContext() {
const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached
const sdk = serverSDK.createClient({
const sdk = globalSDK.createClient({
directory,
throwOnError: true,
})
@@ -96,7 +101,7 @@ export function createServerSyncContext() {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
const queryOptionsApi = makeQueryOptionsApi(() => globalSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@@ -115,7 +120,7 @@ export function createServerSyncContext() {
return pathQuery.data ?? EMPTY
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
return providerQuery.data ?? EMPTY
},
@@ -155,7 +160,7 @@ export function createServerSyncContext() {
queryKey: ["bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
globalSDK: globalSDK.client,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@@ -249,7 +254,7 @@ export function createServerSyncContext() {
loadRootSessionsWithFallback({
directory,
limit,
list: (query) => serverSDK.client.session.list(query),
list: (query) => globalSDK.client.session.list(query),
})
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -335,7 +340,7 @@ export function createServerSyncContext() {
return promise
}
const unsub = serverSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
@@ -394,13 +399,13 @@ export function createServerSyncContext() {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
void globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
void serverSDK.event.start()
void globalSDK.event.start()
}, 0)
}
})
@@ -416,14 +421,8 @@ export function createServerSyncContext() {
}
const updateConfigMutation = useMutation(() => ({
mutationFn: (config: Config) => serverSDK.client.global.config.update({ config }),
onSuccess: () => {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
},
mutationFn: (config: Config) => globalSDK.client.global.config.update({ config }),
onSuccess: () => bootstrap.refetch(),
}))
return {
@@ -447,18 +446,19 @@ export function createServerSyncContext() {
}
}
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
init: () => {
const sync = createServerSyncContext()
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
return {
...sync,
createDirSyncContext: createRefCountMap((dir) => createDirSyncContext(dir, sync)),
}
},
})
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
}
export function useGlobalSync() {
const context = useContext(GlobalSyncContext)
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
export function useQueryOptions() {
return useServerSync().queryOptions
return useGlobalSync().queryOptions
}
@@ -1,78 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap"
import type { State, VcsCache } from "./types"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
describe("bootstrapDirectory", () => {
test("marks a loading directory partial during bootstrap and complete after success", async () => {
const [store, setStore] = createStore<State>({
status: "loading",
agent: [],
command: [],
project: "",
projectMeta: undefined,
icon: undefined,
provider_ready: true,
provider,
config: {},
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
session: [],
sessionTotal: 0,
session_status: {},
session_working(id: string) {
return this.session_status[id]?.type !== "idle"
},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp_ready: true,
mcp: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
part_text_accum_delta: {},
})
await bootstrapDirectory({
directory: "/project",
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
project: [{ id: "project", worktree: "/project" } as Project],
provider,
},
sdk: {
app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) },
config: { get: async () => ({ data: {} }) },
session: { status: async () => ({ data: {} }) },
vcs: { get: async () => ({ data: undefined }) },
command: { list: async () => ({ data: [] }) },
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
mcp: { status: async () => ({ data: {} }) },
provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) },
} as unknown as OpencodeClient,
store,
setStore,
vcsCache: { setStore() {} } as unknown as VcsCache,
loadSessions() {},
translate: (key) => key,
queryClient: new QueryClient(),
})
expect(store.status).toBe("partial")
await new Promise((resolve) => setTimeout(resolve, 80))
expect(store.status).toBe("complete")
})
})
@@ -5,6 +5,7 @@ import type {
PermissionRequest,
Project,
ProviderAuthResponse,
ProviderListResponse,
QuestionRequest,
Session,
Todo,
@@ -18,8 +19,7 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { loadMcpQuery } from "../global-sync"
type GlobalStore = {
ready: boolean
@@ -28,7 +28,7 @@ type GlobalStore = {
session_todo: {
[sessionID: string]: Todo[]
}
provider: NormalizedProviderListResponse
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
@@ -105,7 +105,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
})
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
globalSDK: OpencodeClient
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
@@ -113,12 +113,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.globalSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.serverSDK))
.fetchQuery(loadProjectsQuery(input.globalSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@@ -208,7 +208,7 @@ export async function bootstrapDirectory(input: {
config: Config
path: Path
project: Project[]
provider: NormalizedProviderListResponse
provider: ProviderListResponse
}
queryClient: QueryClient
}) {
@@ -1,63 +1,10 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { createRoot, getOwner, type Owner } from "solid-js"
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
import { createChildStoreManager } from "./child-store"
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
const queryOptionsApi = {
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
path: (directory: string | null) => ({
queryKey: [directory, "path"],
queryFn: async () => ({
state: "",
config: "",
worktree: "",
directory: directory ?? "",
home: "",
}),
}),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi
function createOwner(callback: (owner: Owner) => void) {
return createRoot((dispose) => {
const owner = getOwner()
if (!owner) throw new Error("owner required")
callback(owner)
return dispose
})
}
beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQueries: () => [
{ isLoading: false, data: { state: "", config: "", worktree: "", directory: "", home: "" } },
{ isLoading: false, data: {} },
{ isLoading: false, data: [] },
{ isLoading: false, data: provider },
],
}))
createChildStoreManager = (await import("./child-store")).createChildStoreManager
})
describe("createChildStoreManager", () => {
test("does not evict the active directory during mark", () => {
@@ -75,8 +22,8 @@ describe("createChildStoreManager", () => {
onBootstrap() {},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
queryOptions: {} as any,
global: { provider: null! },
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -90,35 +37,4 @@ describe("createChildStoreManager", () => {
expect(manager.children[directory]).toBeDefined()
})
test("starts new child stores as loading and bootstraps them on first access", () => {
const bootstraps: string[] = []
let manager: ReturnType<typeof createChildStoreManager> | undefined
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
bootstraps.push(directory)
},
onDispose() {},
translate: (key) => key,
queryOptions: queryOptionsApi,
global: { provider },
})
})
try {
if (!manager) throw new Error("manager required")
const [store] = manager.child("/project")
expect(store.status).toBe("loading")
expect(bootstraps).toEqual(["/project"])
} finally {
dispose()
}
})
})
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client"
import {
DIR_IDLE_TTL_MS,
MAX_DIR_STORES,
@@ -15,9 +15,8 @@ import {
} from "./types"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { QueryOptionsApi } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
export function createChildStoreManager(input: {
owner: Owner
@@ -28,7 +27,7 @@ export function createChildStoreManager(input: {
translate: (key: string, vars?: Record<string, string | number>) => string
queryOptions: QueryOptionsApi
global: {
provider: NormalizedProviderListResponse
provider: ProviderListResponse
}
}) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -191,9 +190,10 @@ export function createChildStoreManager(input: {
return !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
return providerQuery.data ?? EMPTY
},
config: {},
@@ -8,6 +8,7 @@ import type {
Part,
Path,
PermissionRequest,
ProviderListResponse,
QuestionRequest,
Session,
SessionStatus,
@@ -15,7 +16,6 @@ import type {
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
@@ -38,7 +38,7 @@ export type State = {
projectMeta: ProjectMeta | undefined
icon: string | undefined
provider_ready: boolean
provider: NormalizedProviderListResponse
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
+5 -16
View File
@@ -1,5 +1,4 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -18,23 +17,13 @@ export function normalizeAgentList(input: unknown): Agent[] {
return Object.values(input).filter(isAgent)
}
export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse {
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
all: new Map(
input.all.map(
(provider) =>
[
provider.id,
{
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
},
] as const,
),
),
all: input.all.map((provider) => ({
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
})),
}
}
-6
View File
@@ -18,7 +18,6 @@ export type Locale =
| "ja"
| "pl"
| "ru"
| "uk"
| "ar"
| "no"
| "br"
@@ -46,7 +45,6 @@ const LOCALES: readonly Locale[] = [
"ja",
"pl",
"ru",
"uk",
"bs",
"ar",
"no",
@@ -67,7 +65,6 @@ const INTL: Record<Locale, string> = {
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
@@ -88,7 +85,6 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
@@ -114,7 +110,6 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
uk: () => merge(import("@/i18n/uk"), import("@opencode-ai/ui/i18n/uk")),
ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
@@ -150,7 +145,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
+13 -13
View File
@@ -2,8 +2,8 @@ import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
@@ -136,8 +136,8 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useServerSDK()
const serverSync = useServerSync()
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const platform = usePlatform()
@@ -386,11 +386,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync.data.project.find((x) => x.id === projectID)
: serverSync.data.project.find((x) => x.worktree === project.worktree)
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
@@ -404,7 +404,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync.data.project) {
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
@@ -470,12 +470,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync.ready) return
if (!globalSync.ready) return
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync.project.icon(project.worktree, project.icon?.override)
globalSync.project.icon(project.worktree, project.icon?.override)
}
})
@@ -509,7 +509,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync.project.meta(worktree, { icon: { color } })
globalSync.project.meta(worktree, { icon: { color } })
continue
}
@@ -531,7 +531,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync.project.loadSessions(project.worktree)
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
@@ -560,7 +560,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync.project.loadSessions(root)
void globalSync.project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
+1 -1
View File
@@ -90,7 +90,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const validModel = (model: ModelKey) => {
const provider = providers.all().get(model.providerID)
const provider = providers.all().find((item) => item.id === model.providerID)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
+7 -7
View File
@@ -2,8 +2,8 @@ import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { useServerSync } from "./server-sync"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
@@ -109,8 +109,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
name: "Notification",
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -207,10 +207,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = serverSync.child(directory, { bootstrap: false })
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return serverSDK.client.session
return globalSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
@@ -285,7 +285,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
}
const unsub = serverSDK.event.listen((e) => {
const unsub = globalSDK.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.error") return
+13 -13
View File
@@ -3,8 +3,8 @@ import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "./server-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import {
@@ -48,13 +48,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
name: "Permission",
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
const directory = decode64(params.dir)
if (!directory) return false
const [store] = serverSync.child(directory)
const [store] = globalSync.child(directory)
return hasPermissionPromptRules(store.config.permission)
})
@@ -86,7 +86,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (!ready()) return
const directory = decode64(params.dir)
if (!directory) return
const [childStore] = serverSync.child(directory)
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
if (typeof perm === "string" && perm === "allow") {
const key = directoryAcceptKey(directory)
@@ -118,7 +118,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
const respond: PermissionRespondFn = (input) => {
serverSDK.client.permission.respond(input).catch(() => {
globalSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
}
@@ -139,7 +139,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function isAutoAccepting(sessionID: string, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory)
}
@@ -148,7 +148,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
const session = directory ? serverSync.child(directory, { bootstrap: false })[0].session : []
const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : []
return autoRespondsPermission(store.autoAccept, session, permission, directory)
}
@@ -159,7 +159,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return next
}
const unsubscribe = serverSDK.event.listen((e) => {
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -178,7 +178,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (!isAutoAcceptingDirectory(directory)) return
@@ -210,7 +210,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}),
)
serverSDK.client.permission
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
@@ -268,7 +268,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
const [childStore] = serverSync.child(directory)
const [childStore] = globalSync.child(directory)
const perm = childStore.config.permission
return typeof perm === "string" && perm === "allow"
},
+2 -28
View File
@@ -1,7 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null
@@ -9,23 +8,13 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type FatalRendererErrorLog = {
error: string
url: string
version?: string
platform: PlatformName
os?: DesktopOS
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: DesktopOS
os?: "macos" | "windows" | "linux"
/** App version */
version?: string
@@ -93,26 +82,11 @@ export type Platform = {
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
/** Get whether native pinch/Ctrl-scroll zoom gestures are enabled (desktop only) */
getPinchZoomEnabled?(): Promise<boolean> | boolean
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
/** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean>
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}
export type DisplayBackend = "auto" | "wayland"
+42 -4
View File
@@ -1,11 +1,49 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServerSDK } from "./server-sdk"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
const serverSDK = useServerSDK()
init: (props: { directory: Accessor<string> }) => {
const globalSDK = useGlobalSDK()
return serverSDK.createDirSdkContext(props.directory)
const directory = createMemo(props.directory)
const client = createMemo(() =>
globalSDK.createClient({
directory: directory(),
throwOnError: true,
}),
)
const emitter = createGlobalEmitter<SDKEventMap>()
createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
})
return {
get directory() {
return directory()
},
get client() {
return client()
},
event: emitter,
get url() {
return globalSDK.url
},
createClient(opts: Parameters<typeof globalSDK.createClient>[0]) {
return globalSDK.createClient(opts)
},
}
},
})
-290
View File
@@ -1,290 +0,0 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
function createServerSdkContext(server: ServerConnection.Any) {
const platform = usePlatform()
const abort = new AbortController()
const eventFetch = (() => {
if (!platform.fetch || !server) return
try {
const url = new URL(server.http.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventSdk = createSdkForServer({
signal: abort.signal,
fetch: eventFetch,
server: server.http,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
const staleDeltas = new Set<string>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}`
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
if (queue.length === 0) return
const events = queue
const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined
queue = buffer
buffer = events
queue.length = 0
coalesced.clear()
staleDeltas.clear()
last = Date.now()
batch(() => {
for (const event of events) {
if (skip && event.payload.type === "message.part.delta") {
const props = event.payload.properties
if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue
}
emitter.emit(event.directory, event.payload)
}
})
buffer.length = 0
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
const resetHeartbeat = () => {
lastEventAt = Date.now()
if (heartbeat) clearTimeout(heartbeat)
heartbeat = setTimeout(() => {
attempt?.abort()
}, HEARTBEAT_TIMEOUT_MS)
}
const clearHeartbeat = () => {
if (!heartbeat) return
clearTimeout(heartbeat)
heartbeat = undefined
}
const start = () => {
if (started) return run
started = true
run = (async () => {
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
if (event.payload.type === "sync") {
continue
}
const payload = event.payload as Event
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
stop()
abort.abort()
flush()
})
const sdk = createSdkForServer({
server: server.http,
fetch: platform.fetch,
throwOnError: true,
})
return {
url: server.http.url,
client: sdk,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
return createSdkForServer({
server: server.http,
fetch: platform.fetch,
...opts,
})
},
}
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
init: () => {
const language = useLanguage()
const server = useServer()
if (!server.current) throw new Error(language.t("error.serverSDK.noServerAvailable"))
const sdk = createServerSdkContext(server.current)
return {
...sdk,
createDirSdkContext: createRefCountMap((dir) => createDirSdkContext(dir, sdk)),
}
},
})
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
function createDirSdkContext(directory: string, serverSDK: ReturnType<typeof createServerSdkContext>) {
const client = serverSDK.createClient({
directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return {
directory,
client,
event: emitter,
get url() {
return serverSDK.url
},
createClient(opts: Parameters<typeof serverSDK.createClient>[0]) {
return serverSDK.createClient(opts)
},
}
}
+64 -20
View File
@@ -1,7 +1,8 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -36,30 +37,24 @@ export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
const servers = [
...input.stored.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
: value,
),
...(input.props ?? []),
]
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
}
return [...deduped.values()]
@@ -127,7 +122,13 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
@@ -145,8 +146,36 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const [state, setState] = createStore({
active: props.defaultServer,
healthy: undefined as boolean | undefined,
})
const healthy = () => state.healthy
function startHealthPolling(conn: ServerConnection.Any) {
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(conn)
.then((next) => {
if (!alive) return
setState("healthy", next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
return () => {
alive = false
clearInterval(interval)
}
}
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
@@ -180,6 +209,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => x.healthy)
createEffect(() => {
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
@@ -192,6 +235,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
return {
ready: isReady,
healthy,
isLocal,
get key() {
return state.active
-20
View File
@@ -1,20 +0,0 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Servers",
init: () => {
const server = useServer()
const health = useServerHealth(
() => server.list,
() => true,
)
return {
list: () => server.list,
health,
}
},
})
-12
View File
@@ -32,8 +32,6 @@ export interface Settings {
shellToolPartsExpanded: boolean
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -55,7 +53,6 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -120,7 +117,6 @@ const defaultSettings: Settings = {
shellToolPartsExpanded: false,
editToolPartsExpanded: false,
showSessionProgressBar: true,
showCustomAgents: false,
},
updates: {
startup: true,
@@ -240,14 +236,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowSessionProgressBar(value: boolean) {
setStore("general", "showSessionProgressBar", value)
},
showCustomAgents: withFallback(() => store.general?.showCustomAgents, defaultSettings.general.showCustomAgents),
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
+509 -6
View File
@@ -1,7 +1,19 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { useServerSync } from "./server-sync"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -9,8 +21,26 @@ function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -108,9 +138,482 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID]
}
export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
return serverSync.createDirSyncContext(sdk.directory)
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
const globalSync = useGlobalSync()
const sdk = useSDK()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
},
})
-223
View File
@@ -1,223 +0,0 @@
export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
| "edit.cut"
| "edit.copy"
| "edit.paste"
| "edit.delete"
| "edit.selectAll"
| "view.reload"
| "view.toggleDevTools"
| "view.resetZoom"
| "view.zoomIn"
| "view.zoomOut"
| "view.toggleFullscreen"
| "window.new"
| "window.close"
| "window.minimize"
| "window.toggleMaximize"
export type DesktopMenuRole =
| "about"
| "close"
| "copy"
| "cut"
| "hide"
| "hideOthers"
| "paste"
| "quit"
| "redo"
| "reload"
| "resetZoom"
| "selectAll"
| "toggleDevTools"
| "togglefullscreen"
| "undo"
| "unhide"
| "windowMenu"
| "zoomIn"
| "zoomOut"
export type DesktopMenuItem = {
type: "item"
label?: string
command?: string
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
enabled?: "updater"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuSeparator = {
type: "separator"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = {
id: string
label: string
role?: DesktopMenuRole
items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[]
}
export const DESKTOP_MENU: DesktopMenu[] = [
{
id: "app",
label: "OpenCode",
platforms: ["macos"],
items: [
{ type: "item", role: "about" },
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" },
{ type: "item", role: "hide" },
{ type: "item", role: "hideOthers" },
{ type: "item", role: "unhide" },
{ type: "separator" },
{ type: "item", role: "quit" },
],
},
{
id: "file",
label: "File",
items: [
{
type: "item",
label: "New Session",
command: "session.new",
accelerator: { macos: "Shift+Cmd+S" },
},
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{
type: "item",
label: "Settings",
command: "settings.open",
accelerator: { windows: "Ctrl+," },
platforms: ["windows"],
},
{
type: "item",
label: "New Window",
action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
},
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
],
},
{
id: "edit",
label: "Edit",
items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{
type: "item",
label: "Select All",
action: "edit.selectAll",
role: "selectAll",
accelerator: { windows: "Ctrl+A" },
},
],
},
{
id: "view",
label: "View",
items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
{ type: "separator" },
{
type: "item",
label: "Actual Size",
action: "view.resetZoom",
role: "resetZoom",
accelerator: { windows: "Ctrl+0" },
},
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
{ type: "separator" },
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
],
},
{
id: "go",
label: "Go",
items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
label: "Previous Project",
command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" },
},
{
type: "item",
label: "Next Project",
command: "project.next",
accelerator: { macos: "Cmd+Option+Down" },
},
],
},
{
id: "window",
label: "Window",
role: "windowMenu",
items: [
{ type: "item", label: "Minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" },
],
},
{
id: "help",
label: "Help",
items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" },
{
type: "item",
label: "Share Feedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
},
{
type: "item",
label: "Report a Bug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
},
],
},
]
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
+9 -26
View File
@@ -1,7 +1,6 @@
import { useServerSync } from "@/context/server-sync"
import { useGlobalSync } from "@/context/global-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { Iterable, pipe } from "effect"
import { createMemo } from "solid-js"
export const popularProviders = [
@@ -17,45 +16,29 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const serverSync = useServerSync()
const globalSync = useGlobalSync()
const params = useParams()
const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = () => {
if (dir()) {
const [projectStore] = serverSync.child(dir())
const [projectStore] = globalSync.child(dir())
if (projectStore.provider_ready) return projectStore.provider
}
return serverSync.data.provider
return globalSync.data.provider
}
return {
all: () => providers().all,
default: () => providers().default,
popular: () =>
pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
connected: () => {
const connected = new Set(providers().connected)
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => connected.has(p.id)),
(v) => Array.from(v),
)
return providers().all.filter((p) => connected.has(p.id))
},
paid: () => {
const connected = new Set(providers().connected)
return [
...Iterable.filter(
providers().all,
([id]) =>
connected.has(id) &&
(id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)),
),
]
return providers().all.filter(
(p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
)
},
}
}
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "الإصدار: {{version}}",
"error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.serverSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:",
@@ -837,8 +837,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "غير معروف",
"error.page.circular": "[دائري]",
"error.serverSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.serverSDK.serverNotAvailable": "الخادم غير متاح",
"error.globalSDK.noServerAvailable": "لا يوجد خادم متاح",
"error.globalSDK.serverNotAvailable": "الخادم غير متاح",
"error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة",
"error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة",
"error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة",
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Versão: {{version}}",
"error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
"error.serverSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"directory.error.invalidUrl": "Diretório inválido na URL.",
"error.chain.unknown": "Erro desconhecido",
"error.chain.causedBy": "Causado por:",
@@ -850,8 +850,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconhecido",
"error.page.circular": "[Circular]",
"error.serverSDK.noServerAvailable": "Nenhum servidor disponível",
"error.serverSDK.serverNotAvailable": "Servidor indisponível",
"error.globalSDK.noServerAvailable": "Nenhum servidor disponível",
"error.globalSDK.serverNotAvailable": "Servidor indisponível",
"error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Korijenski element nije pronađen. Da li si zaboravio da ga dodaš u index.html? Ili je možda id atribut pogrešno napisan?",
"error.serverSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"error.globalSync.connectFailed": "Nije moguće povezati se na server. Da li server radi na `{{url}}`?",
"directory.error.invalidUrl": "Nevažeći direktorij u URL-u.",
"error.chain.unknown": "Nepoznata greška",
@@ -926,8 +926,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nepoznato",
"error.page.circular": "[Kružno]",
"error.serverSDK.noServerAvailable": "Nema dostupnog servera",
"error.serverSDK.serverNotAvailable": "Server nije dostupan",
"error.globalSDK.noServerAvailable": "Nema dostupnog servera",
"error.globalSDK.serverNotAvailable": "Server nije dostupan",
"error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša",
"error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta",
"error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rodelement ikke fundet. Har du glemt at tilføje det til din index.html? Eller måske er id-attributten stavet forkert?",
"error.serverSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"error.globalSync.connectFailed": "Kunne ikke forbinde til server. Kører der en server på `{{url}}`?",
"directory.error.invalidUrl": "Ugyldig mappe i URL.",
"error.chain.unknown": "Ukendt fejl",
@@ -920,8 +920,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukendt",
"error.page.circular": "[Cirkulær]",
"error.serverSDK.noServerAvailable": "Ingen server tilgængelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.globalSDK.noServerAvailable": "Ingen server tilgængelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgængelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon",
+3 -3
View File
@@ -421,7 +421,7 @@ export const dict = {
"error.page.version": "Version: {{version}}",
"error.dev.rootNotFound":
"Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
"error.serverSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
"directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
"error.chain.unknown": "Unbekannter Fehler",
"error.chain.causedBy": "Verursacht durch:",
@@ -862,8 +862,8 @@ export const dict = {
"common.key.insert": "Einfg",
"common.unknown": "unbekannt",
"error.page.circular": "[Zirkulär]",
"error.serverSDK.noServerAvailable": "Kein Server verfügbar",
"error.serverSDK.serverNotAvailable": "Server nicht verfügbar",
"error.globalSDK.noServerAvailable": "Kein Server verfügbar",
"error.globalSDK.serverNotAvailable": "Server nicht verfügbar",
"error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden",
"error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden",
"error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden",
+4 -25
View File
@@ -221,7 +221,6 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "...",
"common.cancel": "Cancel",
"common.clear": "Clear",
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
@@ -417,7 +416,6 @@ export const dict = {
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
@@ -471,7 +469,6 @@ export const dict = {
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.exportLogs": "Export Logs",
"error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}",
@@ -483,9 +480,9 @@ export const dict = {
"error.dev.rootNotFound":
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
"error.serverSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.serverSDK.noServerAvailable": "No server available",
"error.serverSDK.serverNotAvailable": "Server not available",
"error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
"error.globalSDK.noServerAvailable": "No server available",
"error.globalSDK.serverNotAvailable": "Server not available",
"error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache",
"error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata",
"error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon",
@@ -527,15 +524,6 @@ export const dict = {
"home.recentProjects": "Recent projects",
"home.empty.title": "No recent projects",
"home.empty.description": "Get started by opening a local project",
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.empty": "No sessions found",
"home.sessions.empty.description": "Start a new session for this project",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"session.tab.session": "Session",
"session.tab.review": "Review",
@@ -586,9 +574,6 @@ export const dict = {
"session.revertDock.restore": "Restore message",
"session.new.title": "Build anything",
"session.new.project.new": "New project",
"session.new.project.search": "Search projects",
"session.new.project.add": "Add project",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
@@ -769,7 +754,7 @@ export const dict = {
"settings.general.row.followup.option.queue": "Queue",
"settings.general.row.followup.option.steer": "Steer",
"settings.general.row.showFileTree.title": "File tree",
"settings.general.row.showFileTree.description": "Show the file tree panel in desktop sessions",
"settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions",
"settings.general.row.showNavigation.title": "Navigation controls",
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
"settings.general.row.showSearch.title": "Command palette",
@@ -778,8 +763,6 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the desktop title bar",
"settings.general.row.showCustomAgents.title": "Custom agents",
"settings.general.row.showCustomAgents.description": "Show the agent picker in the v2 desktop composer",
"settings.general.row.reasoningSummaries.title": "Show reasoning summaries",
"settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline",
"settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts",
@@ -791,10 +774,6 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
"settings.general.row.wayland.title": "Use native Wayland",
"settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.",
+3 -3
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.dev.rootNotFound":
"Elemento raíz no encontrado. ¿Olvidaste añadirlo a tu index.html? ¿O tal vez el atributo id está mal escrito?",
"error.serverSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"error.globalSync.connectFailed": "No se pudo conectar al servidor. ¿Hay un servidor ejecutándose en `{{url}}`?",
"directory.error.invalidUrl": "URL de directorio inválida.",
"error.chain.unknown": "Error desconocido",
@@ -933,8 +933,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "desconocido",
"error.page.circular": "[Circular]",
"error.serverSDK.noServerAvailable": "Ningún servidor disponible",
"error.serverSDK.serverNotAvailable": "Servidor no disponible",
"error.globalSDK.noServerAvailable": "Ningún servidor disponible",
"error.globalSDK.serverNotAvailable": "Servidor no disponible",
"error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente",
"error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes",
"error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente",
+3 -3
View File
@@ -417,7 +417,7 @@ export const dict = {
"error.page.version": "Version : {{version}}",
"error.dev.rootNotFound":
"Élément racine introuvable. Avez-vous oublié de l'ajouter à votre index.html ? Ou peut-être que l'attribut id est mal orthographié ?",
"error.serverSync.connectFailed":
"error.globalSync.connectFailed":
"Impossible de se connecter au serveur. Y a-t-il un serveur en cours d'exécution à `{{url}}` ?",
"directory.error.invalidUrl": "Répertoire invalide dans l'URL.",
"error.chain.unknown": "Erreur inconnue",
@@ -860,8 +860,8 @@ export const dict = {
"common.key.insert": "Inser",
"common.unknown": "inconnu",
"error.page.circular": "[Circulaire]",
"error.serverSDK.noServerAvailable": "Aucun serveur disponible",
"error.serverSDK.serverNotAvailable": "Serveur non disponible",
"error.globalSDK.noServerAvailable": "Aucun serveur disponible",
"error.globalSDK.serverNotAvailable": "Serveur non disponible",
"error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant",
"error.childStore.persistedProjectMetadataCreateFailed":
"Échec de la création des métadonnées de projet persistantes",
+3 -3
View File
@@ -413,7 +413,7 @@ export const dict = {
"error.page.version": "バージョン: {{version}}",
"error.dev.rootNotFound":
"ルート要素が見つかりません。index.htmlに追加するのを忘れていませんか?またはid属性のスペルが間違っていませんか?",
"error.serverSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"error.globalSync.connectFailed": "サーバーに接続できませんでした。`{{url}}`でサーバーが実行されていますか?",
"directory.error.invalidUrl": "URL内のディレクトリが無効です。",
"error.chain.unknown": "不明なエラー",
"error.chain.causedBy": "原因:",
@@ -844,8 +844,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "不明",
"error.page.circular": "[循環]",
"error.serverSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.serverSDK.serverNotAvailable": "サーバーが利用できません",
"error.globalSDK.noServerAvailable": "利用可能なサーバーがありません",
"error.globalSDK.serverNotAvailable": "サーバーが利用できません",
"error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました",
"error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました",
"error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました",
+3 -3
View File
@@ -412,7 +412,7 @@ export const dict = {
"error.page.version": "버전: {{version}}",
"error.dev.rootNotFound":
"루트 요소를 찾을 수 없습니다. index.html에 추가하는 것을 잊으셨나요? 또는 id 속성의 철자가 틀렸을 수 있습니다.",
"error.serverSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"error.globalSync.connectFailed": "서버에 연결할 수 없습니다. `{{url}}`에서 서버가 실행 중인가요?",
"directory.error.invalidUrl": "URL에 유효하지 않은 디렉터리가 있습니다.",
"error.chain.unknown": "알 수 없는 오류",
"error.chain.causedBy": "원인:",
@@ -839,8 +839,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "알 수 없음",
"error.page.circular": "[순환]",
"error.serverSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.serverSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.globalSDK.noServerAvailable": "사용 가능한 서버 없음",
"error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음",
"error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패",
"error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패",
"error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패",
+3 -3
View File
@@ -463,7 +463,7 @@ export const dict = {
"error.dev.rootNotFound":
"Rotelement ikke funnet. Glemte du å legge det til i index.html? Eller kanskje id-attributten er feilstavet?",
"error.serverSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"error.globalSync.connectFailed": "Kunne ikke koble til server. Kjører det en server på `{{url}}`?",
"directory.error.invalidUrl": "Invalid directory in URL.",
"error.chain.unknown": "Ukjent feil",
@@ -927,8 +927,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ukjent",
"error.page.circular": "[Sirkulær]",
"error.serverSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.serverSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.globalSDK.noServerAvailable": "Ingen server tilgjengelig",
"error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig",
"error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer",
"error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata",
"error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon",
+1 -2
View File
@@ -12,13 +12,12 @@ import { dict as ko } from "./ko"
import { dict as no } from "./no"
import { dict as pl } from "./pl"
import { dict as ru } from "./ru"
import { dict as uk } from "./uk"
import { dict as th } from "./th"
import { dict as zh } from "./zh"
import { dict as zht } from "./zht"
import { dict as tr } from "./tr"
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, uk, th, tr, zh, zht]
const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, tr, zh, zht]
const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
describe("i18n parity", () => {
+3 -3
View File
@@ -414,7 +414,7 @@ export const dict = {
"error.page.version": "Wersja: {{version}}",
"error.dev.rootNotFound":
"Nie znaleziono elementu głównego. Czy zapomniałeś dodać go do swojego index.html? A może atrybut id został błędnie wpisany?",
"error.serverSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"error.globalSync.connectFailed": "Nie można połączyć się z serwerem. Czy serwer działa pod adresem `{{url}}`?",
"directory.error.invalidUrl": "Nieprawidłowy katalog w URL.",
"error.chain.unknown": "Nieznany błąd",
"error.chain.causedBy": "Spowodowany przez:",
@@ -848,8 +848,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "nieznany",
"error.page.circular": "[Cykliczne]",
"error.serverSDK.noServerAvailable": "Brak dostępnego serwera",
"error.serverSDK.serverNotAvailable": "Serwer niedostępny",
"error.globalSDK.noServerAvailable": "Brak dostępnego serwera",
"error.globalSDK.serverNotAvailable": "Serwer niedostępny",
"error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej",
"error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu",
"error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu",
+3 -3
View File
@@ -461,7 +461,7 @@ export const dict = {
"error.dev.rootNotFound":
"Корневой элемент не найден. Вы забыли добавить его в index.html? Или, может быть, атрибут id был написан неправильно?",
"error.serverSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"error.globalSync.connectFailed": "Не удалось подключиться к серверу. Запущен ли сервер по адресу `{{url}}`?",
"directory.error.invalidUrl": "Недопустимая директория в URL.",
"error.chain.unknown": "Неизвестная ошибка",
@@ -929,8 +929,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "неизвестно",
"error.page.circular": "[Циклично]",
"error.serverSDK.noServerAvailable": "Нет доступного сервера",
"error.serverSDK.serverNotAvailable": "Сервер недоступен",
"error.globalSDK.noServerAvailable": "Нет доступного сервера",
"error.globalSDK.serverNotAvailable": "Сервер недоступен",
"error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта",
"error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта",
+3 -3
View File
@@ -459,7 +459,7 @@ export const dict = {
"error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
"error.serverSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
"directory.error.invalidUrl": "ไดเรกทอรีใน URL ไม่ถูกต้อง",
"error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
@@ -916,8 +916,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "ไม่ทราบ",
"error.page.circular": "[วงกลม]",
"error.serverSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.serverSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์",
"error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน",
"error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร",
"error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร",
"error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร",
+3 -3
View File
@@ -465,7 +465,7 @@ export const dict = {
"error.dev.rootNotFound":
"Kök eleman bulunamadı. index.html dosyanıza eklemeyi unuttunuz mu? Ya da id özelliği yanlış mı yazıldı?",
"error.serverSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"error.globalSync.connectFailed": "Sunucuya bağlanılamadı. `{{url}}` adresinde çalışan bir sunucu var mı?",
"directory.error.invalidUrl": "URL'de geçersiz dizin.",
"error.chain.unknown": "Bilinmeyen hata",
@@ -935,8 +935,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "bilinmiyor",
"error.page.circular": "[Döngüsel]",
"error.serverSDK.noServerAvailable": "Sunucu yok",
"error.serverSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.globalSDK.noServerAvailable": "Sunucu yok",
"error.globalSDK.serverNotAvailable": "Sunucu mevcut değil",
"error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı",
"error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı",
"error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı",
-969
View File
@@ -1,969 +0,0 @@
export const dict = {
"command.category.suggested": "Рекомендовані",
"command.category.view": "Вигляд",
"command.category.project": "Проєкт",
"command.category.provider": "Провайдер",
"command.category.server": "Сервер",
"command.category.session": "Сесія",
"command.category.theme": "Тема",
"command.category.language": "Мова",
"command.category.file": "Файл",
"command.category.context": "Контекст",
"command.category.terminal": "Термінал",
"command.category.model": "Модель",
"command.category.mcp": "MCP",
"command.category.agent": "Агент",
"command.category.permissions": "Дозволи",
"command.category.workspace": "Робоча область",
"command.category.settings": "Налаштування",
"theme.scheme.system": "Системна",
"theme.scheme.light": "Світла",
"theme.scheme.dark": "Темна",
"command.sidebar.toggle": "Перемкнути бічну панель",
"command.project.open": "Відкрити проєкт",
"command.project.previous": "Попередній проєкт",
"command.project.next": "Наступний проєкт",
"command.project.index": "Перемкнути на проєкт {{index}}",
"command.provider.connect": "Підключити провайдера",
"command.server.switch": "Перемкнути сервер",
"command.settings.open": "Відкрити налаштування",
"command.session.previous": "Попередня сесія",
"command.session.next": "Наступна сесія",
"command.session.previous.unseen": "Попередня непрочитана сесія",
"command.session.next.unseen": "Наступна непрочитана сесія",
"command.session.archive": "Архівувати сесію",
"command.palette": "Палітра команд",
"command.theme.cycle": "Перемкнути тему",
"command.theme.set": "Використати тему: {{theme}}",
"command.theme.scheme.cycle": "Перемкнути кольорову схему",
"command.theme.scheme.set": "Використати кольорову схему: {{scheme}}",
"command.language.cycle": "Перемкнути мову",
"command.language.set": "Використати мову: {{language}}",
"command.session.new": "Нова сесія",
"command.file.open": "Відкрити файл",
"command.tab.close": "Закрити вкладку",
"command.context.addSelection": "Додати виділення до контексту",
"command.context.addSelection.description": "Додати вибрані рядки з поточного файлу",
"command.input.focus": "Фокус на полі введення",
"command.terminal.toggle": "Перемкнути термінал",
"command.fileTree.toggle": "Перемкнути дерево файлів",
"command.review.toggle": "Перемкнути огляд",
"command.terminal.new": "Новий термінал",
"command.terminal.new.description": "Створити нову вкладку термінала",
"command.steps.toggle": "Перемкнути кроки",
"command.steps.toggle.description": "Показати або приховати кроки для поточного повідомлення",
"command.message.previous": "Попереднє повідомлення",
"command.message.previous.description": "Перейти до попереднього повідомлення користувача",
"command.message.next": "Наступне повідомлення",
"command.message.next.description": "Перейти до наступного повідомлення користувача",
"command.model.choose": "Вибрати модель",
"command.model.choose.description": "Вибрати іншу модель",
"command.mcp.toggle": "Перемкнути MCP",
"command.mcp.toggle.description": "Перемкнути MCP",
"command.agent.cycle": "Перемкнути агента",
"command.agent.cycle.description": "Перемкнути на наступного агента",
"command.agent.cycle.reverse": "Перемкнути агента в зворотному напрямку",
"command.agent.cycle.reverse.description": "Перемкнути на попереднього агента",
"command.model.variant.cycle": "Перемкнути рівень мислення",
"command.model.variant.cycle.description": "Перемкнути на наступний рівень зусилля",
"command.prompt.mode.shell": "Команда",
"command.prompt.mode.normal": "Запит",
"command.permissions.autoaccept.enable": "Автоматично приймати дозволи",
"command.permissions.autoaccept.disable": "Зупинити автоматичне прийняття дозволів",
"command.workspace.toggle": "Перемкнути робочі області",
"command.workspace.toggle.description": "Увімкнути або вимкнути декілька робочих областей на бічній панелі",
"command.session.undo": "Скасувати",
"command.session.undo.description": "Скасувати останнє повідомлення",
"command.session.redo": "Повторити",
"command.session.redo.description": "Повторити останнє скасоване повідомлення",
"command.session.compact": "Стиснути сесію",
"command.session.compact.description": "Підсумувати сесію, щоб зменшити розмір контексту",
"command.session.fork": "Відгалузити від повідомлення",
"command.session.fork.description": "Створити нову сесію з попереднього повідомлення",
"command.session.share": "Поділитися сесією",
"command.session.share.description": "Поділитися цією сесією та скопіювати URL у буфер обміну",
"command.session.unshare": "Припинити поширення сесії",
"command.session.unshare.description": "Припинити поширення цієї сесії",
"palette.search.placeholder": "Пошук файлів, команд і сесій",
"palette.empty": "Результатів не знайдено",
"palette.group.commands": "Команди",
"palette.group.files": "Файли",
"dialog.provider.search.placeholder": "Пошук провайдерів",
"dialog.provider.empty": "Провайдерів не знайдено",
"dialog.provider.group.popular": "Популярні",
"dialog.provider.group.other": "Інші",
"dialog.provider.tag.recommended": "Рекомендовані",
"dialog.provider.opencode.note": "Відібрані моделі, включаючи Claude, GPT, Gemini та інші",
"dialog.provider.opencode.tagline": "Надійні оптимізовані моделі",
"dialog.provider.opencodeGo.tagline": "Недорога підписка для всіх",
"dialog.provider.anthropic.note": "Прямий доступ до моделей Claude, включаючи Pro та Max",
"dialog.provider.copilot.note": "Моделі AI для допомоги в кодуванні через GitHub Copilot",
"dialog.provider.openai.note": "Моделі GPT для швидких і універсальних завдань AI",
"dialog.provider.google.note": "Моделі Gemini для швидких структурованих відповідей",
"dialog.provider.openrouter.note": "Доступ до всіх підтримуваних моделей від одного провайдера",
"dialog.provider.vercel.note": "Уніфікований доступ до моделей AI з інтелектуальною маршрутизацією",
"dialog.model.select.title": "Вибрати модель",
"dialog.model.search.placeholder": "Пошук моделей",
"dialog.model.empty": "Немає результатів моделей",
"dialog.model.manage": "Керувати моделями",
"dialog.model.manage.description": "Налаштуйте, які моделі відображатимуться у виборі моделей.",
"dialog.model.manage.provider.toggle": "Перемкнути всі моделі {{provider}}",
"dialog.model.unpaid.freeModels.title": "Безкоштовні моделі від OpenCode",
"dialog.model.unpaid.addMore.title": "Додати більше моделей від популярних провайдерів",
"dialog.provider.viewAll": "Показати більше провайдерів",
"provider.connect.title": "Підключити {{provider}}",
"provider.connect.title.anthropicProMax": "Увійти з Claude Pro/Max",
"provider.connect.selectMethod": "Виберіть спосіб входу для {{provider}}.",
"provider.connect.method.apiKey": "Ключ API",
"provider.connect.status.inProgress": "Авторизація виконується...",
"provider.connect.status.waiting": "Очікування авторизації...",
"provider.connect.status.failed": "Авторизація не вдалася: {{error}}",
"provider.connect.apiKey.description":
"Введіть ключ API {{provider}}, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.apiKey.label": "Ключ API {{provider}}",
"provider.connect.apiKey.placeholder": "Ключ API",
"provider.connect.apiKey.required": "Ключ API обов'язковий",
"provider.connect.opencodeZen.line1":
"OpenCode Zen надає доступ до відібраного набору надійних оптимізованих моделей для агентів кодування.",
"provider.connect.opencodeZen.line2":
"З одним ключем API ви отримаєте доступ до таких моделей, як Claude, GPT, Gemini, GLM та інших.",
"provider.connect.opencodeZen.visit.prefix": "Відвідайте ",
"provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
"provider.connect.opencodeZen.visit.suffix": ", щоб отримати ключ API.",
"provider.connect.oauth.code.visit.prefix": "Відвідайте ",
"provider.connect.oauth.code.visit.link": "це посилання",
"provider.connect.oauth.code.visit.suffix":
", щоб отримати код авторизації, підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.code.label": "Код авторизації {{method}}",
"provider.connect.oauth.code.placeholder": "Код авторизації",
"provider.connect.oauth.code.required": "Код авторизації обов'язковий",
"provider.connect.oauth.code.invalid": "Недійсний код авторизації",
"provider.connect.oauth.auto.visit.prefix": "Відвідайте ",
"provider.connect.oauth.auto.visit.link": "це посилання",
"provider.connect.oauth.auto.visit.suffix":
" і введіть код нижче, щоб підключити обліковий запис і використовувати моделі {{provider}} у OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Код підтвердження",
"provider.connect.toast.connected.title": "{{provider}} підключено",
"provider.connect.toast.connected.description": "Моделі {{provider}} тепер доступні для використання.",
"provider.custom.title": "Користувацький провайдер",
"provider.custom.description.prefix": "Налаштуйте провайдера, сумісного з OpenAI. Перегляньте ",
"provider.custom.description.link": "документацію з налаштування провайдера",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID провайдера",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Малі літери, цифри, дефіси або підкреслення",
"provider.custom.field.name.label": "Відображувана назва",
"provider.custom.field.name.placeholder": "Мій AI Провайдер",
"provider.custom.field.baseURL.label": "Базовий URL",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "Ключ API",
"provider.custom.field.apiKey.placeholder": "Ключ API",
"provider.custom.field.apiKey.description":
"Необов'язково. Залиште порожнім, якщо ви керуєте авторизацією через заголовки.",
"provider.custom.models.label": "Моделі",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "Назва",
"provider.custom.models.name.placeholder": "Відображувана назва",
"provider.custom.models.remove": "Видалити модель",
"provider.custom.models.add": "Додати модель",
"provider.custom.headers.label": "Заголовки (необов'язково)",
"provider.custom.headers.key.label": "Заголовок",
"provider.custom.headers.key.placeholder": "Назва-Заголовка",
"provider.custom.headers.value.label": "Значення",
"provider.custom.headers.value.placeholder": "значення",
"provider.custom.headers.remove": "Видалити заголовок",
"provider.custom.headers.add": "Додати заголовок",
"provider.custom.error.providerID.required": "ID провайдера обов'язкове",
"provider.custom.error.providerID.format": "Використовуйте малі літери, цифри, дефіси або підкреслення",
"provider.custom.error.providerID.exists": "ID провайдера вже існує",
"provider.custom.error.name.required": "Відображувана назва обов'язкова",
"provider.custom.error.baseURL.required": "Базовий URL обов'язковий",
"provider.custom.error.baseURL.format": "Має починатися з http:// або https://",
"provider.custom.error.required": "Обов'язково",
"provider.custom.error.duplicate": "Дублікат",
"provider.disconnect.toast.disconnected.title": "{{provider}} відключено",
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
"model.provider.google": "Google",
"model.provider.xai": "xAI",
"model.provider.meta": "Meta",
"model.input.text": "текст",
"model.input.image": "зображення",
"model.input.audio": "аудіо",
"model.input.video": "відео",
"model.input.pdf": "pdf",
"model.tooltip.allows": "Дозволяє: {{inputs}}",
"model.tooltip.reasoning.allowed": "Підтримує мислення",
"model.tooltip.reasoning.none": "Без мислення",
"model.tooltip.context": "Ліміт контексту {{limit}}",
"common.search.placeholder": "Пошук",
"common.goBack": "Назад",
"common.goForward": "Вперед",
"common.loading": "Завантаження",
"common.loading.ellipsis": "...",
"common.cancel": "Скасувати",
"common.open": "Відкрити",
"common.connect": "Підключити",
"common.disconnect": "Відключити",
"common.continue": "Продовжити",
"common.submit": "Надіслати",
"common.save": "Зберегти",
"common.saving": "Збереження...",
"common.default": "За замовчуванням",
"common.attachment": "вкладення",
"prompt.placeholder.shell": "Введіть команду термінала... {{example}}",
"prompt.placeholder.normal": 'Запитайте що завгодно... "{{example}}"',
"prompt.placeholder.simple": "Запитайте що завгодно...",
"prompt.placeholder.summarizeComments": "Підсумувати коментарі…",
"prompt.placeholder.summarizeComment": "Підсумувати коментар…",
"prompt.mode.shell": "Команда",
"prompt.mode.normal": "Запит",
"prompt.mode.shell.exit": "esc для виходу",
"session.child.promptDisabled": "Сесії підагентів не можна надсилати запити.",
"session.child.backToParent": "Назад до основної сесії.",
"prompt.example.1": "Виправити TODO у коді",
"prompt.example.2": "Який технологічний стек цього проєкту?",
"prompt.example.3": "Виправити зламані тести",
"prompt.example.4": "Пояснити, як працює автентифікація",
"prompt.example.5": "Знайти та виправити вразливості безпеки",
"prompt.example.6": "Додати модульні тести для сервісу користувача",
"prompt.example.7": "Рефакторити цю функцію, щоб зробити її більш читабельною",
"prompt.example.8": "Що означає ця помилка?",
"prompt.example.9": "Допоможіть мені налагодити цю проблему",
"prompt.example.10": "Згенерувати документацію API",
"prompt.example.11": "Оптимізувати запити до бази даних",
"prompt.example.12": "Додати валідацію введення",
"prompt.example.13": "Створити новий компонент для...",
"prompt.example.14": "Як розгорнути цей проєкт?",
"prompt.example.15": "Перевірити мій код на відповідність найкращим практикам",
"prompt.example.16": "Додати обробку помилок до цієї функції",
"prompt.example.17": "Пояснити цей регулярний вираз",
"prompt.example.18": "Конвертувати це в TypeScript",
"prompt.example.19": "Додати логування по всьому коду",
"prompt.example.20": "Які залежності застарілі?",
"prompt.example.21": "Допоможіть написати скрипт міграції",
"prompt.example.22": "Реалізувати кешування для цього ендпоінта",
"prompt.example.23": "Додати посторінкову навігацію до цього списку",
"prompt.example.24": "Створити команду CLI для...",
"prompt.example.25": "Як тут працюють змінні середовища?",
"prompt.popover.emptyResults": "Немає відповідних результатів",
"prompt.popover.emptyCommands": "Немає відповідних команд",
"prompt.dropzone.label": "Перетягніть сюди зображення, PDF або текстові файли",
"prompt.dropzone.file.label": "Перетягніть, щоб @згадати файл",
"prompt.slash.badge.custom": "користувацький",
"prompt.slash.badge.skill": "навичка",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "активний",
"prompt.context.includeActiveFile": "Включити активний файл",
"prompt.context.removeActiveFile": "Видалити активний файл з контексту",
"prompt.context.removeFile": "Видалити файл з контексту",
"prompt.action.attachFile": "Додати файли",
"prompt.attachment.remove": "Видалити вкладення",
"prompt.action.send": "Надіслати",
"prompt.action.stop": "Зупинити",
"prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення",
"prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.",
"prompt.toast.modelAgentRequired.title": "Виберіть агента та модель",
"prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.",
"prompt.toast.worktreeCreateFailed.title": "Не вдалося створити робоче дерево",
"prompt.toast.sessionCreateFailed.title": "Не вдалося створити сесію",
"prompt.toast.shellSendFailed.title": "Не вдалося надіслати команду термінала",
"prompt.toast.commandSendFailed.title": "Не вдалося надіслати команду",
"prompt.toast.promptSendFailed.title": "Не вдалося надіслати запит",
"prompt.toast.promptSendFailed.description": "Не вдалося отримати сесію",
"dialog.mcp.title": "MCP",
"dialog.mcp.description": "{{enabled}} з {{total}} увімкнено",
"dialog.mcp.empty": "MCP не налаштовано",
"dialog.lsp.empty": "LSP автоматично виявлені за типами файлів",
"dialog.plugins.empty": "Плагіни налаштовані в opencode.json",
"mcp.status.connected": "підключено",
"mcp.status.failed": "помилка",
"mcp.status.needs_auth": "потрібна авторизація",
"mcp.status.disabled": "вимкнено",
"mcp.auth.clickToAuthenticate": "Натисніть для автентифікації",
"dialog.fork.empty": "Немає повідомлень для відгалуження",
"dialog.directory.search.placeholder": "Пошук папок",
"dialog.directory.empty": "Папок не знайдено",
"app.server.unreachable": "Не вдалося досягти {{server}}",
"app.server.retrying": "Автоматичне повторення...",
"app.server.otherServers": "Інші сервери",
"dialog.server.title": "Сервери",
"dialog.server.description": "Перемкніть сервер OpenCode, до якого підключається ця програма.",
"dialog.server.search.placeholder": "Пошук серверів",
"dialog.server.empty": "Ще немає серверів",
"dialog.server.add.title": "Додати сервер",
"dialog.server.add.url": "Адреса сервера",
"dialog.server.add.placeholder": "http://localhost:4096",
"dialog.server.add.error": "Не вдалося підключитися до сервера",
"dialog.server.add.checking": "Перевірка...",
"dialog.server.add.button": "Додати сервер",
"dialog.server.add.name": "Назва сервера (необов'язково)",
"dialog.server.add.namePlaceholder": "Localhost",
"dialog.server.add.username": "Ім'я користувача (необов'язково)",
"dialog.server.add.usernamePlaceholder": "ім'я користувача",
"dialog.server.add.password": "Пароль (необов'язково)",
"dialog.server.add.passwordPlaceholder": "пароль",
"dialog.server.edit.title": "Редагувати сервер",
"dialog.server.default.title": "Сервер за замовчуванням",
"dialog.server.default.description":
"Підключатися до цього сервера під час запуску програми замість запуску локального сервера. Потребує перезапуску.",
"dialog.server.default.none": "Сервер не вибрано",
"dialog.server.default.set": "Встановити поточний сервер як сервер за замовчуванням",
"dialog.server.default.clear": "Очистити",
"dialog.server.action.remove": "Видалити сервер",
"dialog.server.menu.edit": "Редагувати",
"dialog.server.menu.default": "Встановити за замовчуванням",
"dialog.server.menu.defaultRemove": "Видалити за замовчуванням",
"dialog.server.menu.delete": "Видалити",
"dialog.server.current": "Поточний сервер",
"dialog.server.status.default": "За замовчуванням",
"server.row.noUsername": "без імені користувача",
"dialog.project.edit.title": "Редагувати проєкт",
"dialog.project.edit.name": "Назва",
"dialog.project.edit.icon": "Іконка",
"dialog.project.edit.icon.alt": "Іконка проєкту",
"dialog.project.edit.icon.hint": "Натисніть або перетягніть зображення",
"dialog.project.edit.icon.recommended": "Рекомендовано: 128x128px",
"dialog.project.edit.color": "Колір",
"dialog.project.edit.color.select": "Вибрати колір {{color}}",
"dialog.project.edit.worktree.startup": "Скрипт запуску робочої області",
"dialog.project.edit.worktree.startup.description": "Виконується після створення нової робочої області (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "напр. bun install",
"dialog.releaseNotes.action.getStarted": "Розпочати",
"dialog.releaseNotes.action.next": "Далі",
"dialog.releaseNotes.action.hideFuture": "Не показувати це в майбутньому",
"dialog.releaseNotes.media.alt": "Попередній перегляд релізу",
"context.breakdown.title": "Розподіл контексту",
"context.breakdown.note":
'Приблизний розподіл вхідних токенів. "Інше" включає визначення інструментів і накладні витрати.',
"context.breakdown.system": "Система",
"context.breakdown.user": "Користувач",
"context.breakdown.assistant": "Асистент",
"context.breakdown.tool": "Виклики інструментів",
"context.breakdown.other": "Інше",
"context.systemPrompt.title": "Системний запит",
"context.rawMessages.title": "Сировинні повідомлення",
"context.stats.session": "Сесія",
"context.stats.messages": "Повідомлення",
"context.stats.provider": "Провайдер",
"context.stats.model": "Модель",
"context.stats.limit": "Ліміт контексту",
"context.stats.totalTokens": "Всього токенів",
"context.stats.usage": "Використання",
"context.stats.inputTokens": "Вхідні токени",
"context.stats.outputTokens": "Вихідні токени",
"context.stats.reasoningTokens": "Токени мислення",
"context.stats.cacheTokens": "Токени кешу (читання/запис)",
"context.stats.userMessages": "Повідомлення користувача",
"context.stats.assistantMessages": "Повідомлення асистента",
"context.stats.totalCost": "Загальна вартість",
"context.stats.sessionCreated": "Сесію створено",
"context.stats.lastActivity": "Остання активність",
"context.usage.tokens": "Токени",
"context.usage.usage": "Використання",
"context.usage.cost": "Вартість",
"context.usage.clickToView": "Натисніть, щоб переглянути контекст",
"context.usage.view": "Переглянути використання контексту",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.uk": "Українська",
"language.th": "ไทย",
"language.tr": "Türkçe",
"toast.language.title": "Мова",
"toast.language.description": "Перемкнено на {{language}}",
"toast.theme.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": "Запити дозволів будуть автоматично схвалюватися",
"toast.permissions.autoaccept.off.title": "Автоматичне прийняття дозволів зупинено",
"toast.permissions.autoaccept.off.description": "Запити дозволів вимагатимуть схвалення",
"toast.model.none.title": "Модель не вибрано",
"toast.model.none.description": "Підключіть провайдера, щоб підсумувати цю сесію",
"toast.file.loadFailed.title": "Не вдалося завантажити файл",
"toast.file.listFailed.title": "Не вдалося отримати список файлів",
"toast.context.noLineSelection.title": "Не вибрано рядків",
"toast.context.noLineSelection.description": "Спочатку виберіть діапазон рядків у вкладці файлу.",
"toast.session.share.copyFailed.title": "Не вдалося скопіювати URL у буфер обміну",
"toast.session.share.success.title": "Сесію опубліковано",
"toast.session.share.success.description": "Посилання скопійовано в буфер обміну!",
"toast.session.share.failed.title": "Не вдалося опублікувати сесію",
"toast.session.share.failed.description": "Під час публікації сесії сталася помилка",
"toast.session.unshare.success.title": "Поширення сесії припинено",
"toast.session.unshare.success.description": "Поширення сесії успішно припинено!",
"toast.session.unshare.failed.title": "Не вдалося припинити поширення сесії",
"toast.session.unshare.failed.description": "Під час припинення поширення сесії сталася помилка",
"toast.session.listFailed.title": "Не вдалося завантажити сесії для {{project}}",
"toast.project.reloadFailed.title": "Не вдалося перезавантажити {{project}}",
"toast.update.title": "Доступне оновлення",
"toast.update.description": "Нова версія OpenCode ({{version}}) тепер доступна для встановлення.",
"toast.update.action.installRestart": "Встановити та перезапустити",
"toast.update.action.notYet": "Не зараз",
"error.page.title": "Щось пішло не так",
"error.page.description": "Під час завантаження програми сталася помилка.",
"error.page.details.label": "Деталі помилки",
"error.page.action.restart": "Перезапустити",
"error.page.action.report": "Повідомити про помилку",
"error.page.action.reported": "Помилку повідомлено",
"error.page.action.checking": "Перевірка...",
"error.page.action.checkUpdates": "Перевірити оновлення",
"error.page.action.updateTo": "Оновити до {{version}}",
"error.page.circular": "[Циклічне]",
"error.page.report.prefix": "Будь ласка, повідомте про цю помилку команді OpenCode",
"error.page.report.discord": "на Discord",
"error.page.version": "Версія: {{version}}",
"error.dev.rootNotFound":
"Кореневий елемент не знайдено. Ви забули додати його до index.html? Або, можливо, атрибут id було написано з помилкою?",
"error.serverSync.connectFailed": "Не вдалося підключитися до сервера. Чи працює сервер за адресою `{{url}}`?",
"error.serverSDK.noServerAvailable": "Сервер недоступний",
"error.serverSDK.serverNotAvailable": "Сервер недоступний",
"error.childStore.persistedCacheCreateFailed": "Не вдалося створити постійний кеш",
"error.childStore.persistedProjectMetadataCreateFailed": "Не вдалося створити постійні метадані проєкту",
"error.childStore.persistedProjectIconCreateFailed": "Не вдалося створити постійну іконку проєкту",
"error.childStore.storeCreateFailed": "Не вдалося створити сховище",
"directory.error.invalidUrl": "Недійсний каталог у URL.",
"error.chain.unknown": "Невідома помилка",
"error.server.invalidConfiguration": "Недійсна конфігурація",
"error.chain.causedBy": "Причина:",
"error.chain.apiError": "Помилка API",
"error.chain.status": "Статус: {{status}}",
"error.chain.retryable": "Повторювано: {{retryable}}",
"error.chain.responseBody": "Тіло відповіді:\n{{body}}",
"error.chain.didYouMean": "Можливо, ви мали на увазі: {{suggestions}}",
"error.chain.modelNotFound": "Модель не знайдено: {{provider}}/{{model}}",
"error.chain.checkConfig": "Перевірте назви провайдерів/моделей у конфігурації (opencode.json)",
"error.chain.mcpFailed":
'Сервер MCP "{{name}}" не працює. Зверніть увагу, OpenCode ще не підтримує автентифікацію MCP.',
"error.chain.providerAuthFailed": "Автентифікація провайдера не вдалася ({{provider}}): {{message}}",
"error.chain.providerInitFailed":
'Не вдалося ініціалізувати провайдера "{{provider}}". Перевірте облікові дані та конфігурацію.',
"error.chain.configJsonInvalid": "Файл конфігурації {{path}} не є дійсним JSON(C)",
"error.chain.configJsonInvalidWithMessage": "Файл конфігурації {{path}} не є дійсним JSON(C): {{message}}",
"error.chain.configDirectoryTypo":
'Каталог "{{dir}}" у {{path}} недійсний. Перейменуйте каталог на "{{suggestion}}" або видаліть його. Це поширена помилка.',
"error.chain.configFrontmatterError": "Не вдалося розібрати frontmatter у {{path}}:\n{{message}}",
"error.chain.configInvalid": "Файл конфігурації {{path}} недійсний",
"error.chain.configInvalidWithMessage": "Файл конфігурації {{path}} недійсний: {{message}}",
"notification.permission.title": "Потрібен дозвіл",
"notification.permission.description": "{{sessionTitle}} у {{projectName}} потребує дозволу",
"notification.question.title": "Запитання",
"notification.question.description": "{{sessionTitle}} у {{projectName}} має запитання",
"notification.action.goToSession": "Перейти до сесії",
"notification.session.responseReady.title": "Відповідь готова",
"notification.session.error.title": "Помилка сесії",
"notification.session.error.fallbackDescription": "Сталася помилка",
"home.recentProjects": "Нещодавні проєкти",
"home.empty.title": "Немає нещодавніх проєктів",
"home.empty.description": "Почніть, відкривши локальний проєкт",
"session.tab.session": "Сесія",
"session.tab.review": "Огляд",
"session.tab.context": "Контекст",
"session.panel.reviewAndFiles": "Огляд і файли",
"session.review.filesChanged": "Змінено файлів: {{count}}",
"session.review.change.one": "Зміна",
"session.review.change.other": "Зміни",
"session.review.loadingChanges": "Завантаження змін...",
"session.review.empty": "У цій сесії ще немає змін",
"session.review.noVcs": "Систему контролю версій Git не виявлено, зміни не відображаються",
"session.review.noVcs.createGit.title": "Створити Git-репозиторій",
"session.review.noVcs.createGit.description": "Відстежуйте, переглядайте та скасовуйте зміни в цьому проєкті",
"session.review.noVcs.createGit.actionLoading": "Створення Git-репозиторію...",
"session.review.noVcs.createGit.action": "Створити Git-репозиторій",
"session.review.noSnapshot": "Відстеження знімків вимкнено в конфігурації, тому зміни сесії недоступні",
"session.review.noChanges": "Немає змін",
"session.review.noUncommittedChanges": "Ще немає незафіксованих змін",
"session.review.noBranchChanges": "Ще немає змін у гілці",
"session.files.selectToOpen": "Виберіть файл для відкриття",
"session.files.all": "Усі файли",
"session.files.empty": "Немає файлів",
"session.files.binaryContent": "Бінарний файл (вміст не може бути відображено)",
"session.messages.renderEarlier": "Відобразити раніші повідомлення",
"session.messages.loadingEarlier": "Завантаження раніших повідомлень...",
"session.messages.loadEarlier": "Завантажити раніші повідомлення",
"session.messages.loading": "Завантаження повідомлень...",
"session.messages.jumpToLatest": "Перейти до останніх",
"session.context.addToContext": "Додати {{selection}} до контексту",
"session.todo.title": "Завдання",
"session.todo.collapse": "Згорнути",
"session.todo.expand": "Розгорнути",
"session.todo.progress": "Виконано {{done}} з {{total}} завдань",
"session.question.progress": "{{current}} з {{total}} запитань",
"session.followupDock.summary.one": "{{count}} повідомлення в черзі",
"session.followupDock.summary.other": "{{count}} повідомлень у черзі",
"session.followupDock.sendNow": "Надіслати зараз",
"session.followupDock.edit": "Редагувати",
"session.followupDock.collapse": "Згорнути повідомлення в черзі",
"session.followupDock.expand": "Розгорнути повідомлення в черзі",
"session.revertDock.summary.one": "{{count}} скасоване повідомлення",
"session.revertDock.summary.other": "{{count}} скасованих повідомлень",
"session.revertDock.collapse": "Згорнути скасовані повідомлення",
"session.revertDock.expand": "Розгорнути скасовані повідомлення",
"session.revertDock.restore": "Відновити повідомлення",
"session.new.title": "Створити що завгодно",
"session.new.worktree.main": "Основна гілка",
"session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})",
"session.new.worktree.create": "Створити нове робоче дерево",
"session.new.lastModified": "Востаннє змінено",
"session.header.search.placeholder": "Пошук {{project}}",
"session.header.searchFiles": "Пошук файлів",
"session.header.openIn": "Відкрити в",
"session.header.open.action": "Відкрити {{app}}",
"session.header.open.ariaLabel": "Відкрити в {{app}}",
"session.header.open.menu": "Параметри відкриття",
"session.header.open.copyPath": "Копіювати шлях",
"session.header.open.finder": "Finder",
"session.header.open.fileExplorer": "Провідник файлів",
"session.header.open.fileManager": "Файловий менеджер",
"session.header.open.app.vscode": "VS Code",
"session.header.open.app.cursor": "Cursor",
"session.header.open.app.zed": "Zed",
"session.header.open.app.textmate": "TextMate",
"session.header.open.app.antigravity": "Antigravity",
"session.header.open.app.terminal": "Термінал",
"session.header.open.app.iterm2": "iTerm2",
"session.header.open.app.ghostty": "Ghostty",
"session.header.open.app.warp": "Warp",
"session.header.open.app.xcode": "Xcode",
"session.header.open.app.androidStudio": "Android Studio",
"session.header.open.app.powershell": "PowerShell",
"session.header.open.app.sublimeText": "Sublime Text",
"status.popover.trigger": "Статус",
"status.popover.ariaLabel": "Конфігурації серверів",
"status.popover.tab.servers": "Сервери",
"status.popover.tab.mcp": "MCP",
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Плагіни",
"status.popover.action.manageServers": "Керувати серверами",
"session.share.popover.title": "Опублікувати в інтернеті",
"session.share.popover.description.shared":
"Ця сесія є публічною в інтернеті. Вона доступна будь-кому за посиланням.",
"session.share.popover.description.unshared":
"Опублікуйте сесію публічно в інтернеті. Вона буде доступна будь-кому за посиланням.",
"session.share.action.share": "Поділитися",
"session.share.action.publish": "Опублікувати",
"session.share.action.publishing": "Публікація...",
"session.share.action.unpublish": "Скасувати публікацію",
"session.share.action.unpublishing": "Скасування публікації...",
"session.share.action.view": "Переглянути",
"session.share.copy.copied": "Скопійовано",
"session.share.copy.copyLink": "Копіювати посилання",
"lsp.tooltip.none": "Немає серверів LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Завантаження запиту...",
"terminal.loading": "Завантаження термінала...",
"terminal.title": "Термінал",
"terminal.title.numbered": "Термінал {{number}}",
"terminal.close": "Закрити термінал",
"terminal.connectionLost.title": "З'єднання втрачено",
"terminal.connectionLost.abnormalClose": "WebSocket закрито аномально: {{code}}",
"terminal.connectionLost.description":
"З'єднання з терміналом було перервано. Це може статися під час перезапуску сервера.",
"common.closeTab": "Закрити вкладку",
"common.dismiss": "Відхилити",
"common.moreCountSuffix": " (ще {{count}})",
"common.requestFailed": "Запит не виконано",
"common.moreOptions": "Більше опцій",
"common.learnMore": "Дізнатися більше",
"common.rename": "Перейменувати",
"common.reset": "Скинути",
"common.archive": "Архівувати",
"common.delete": "Видалити",
"common.close": "Закрити",
"common.edit": "Редагувати",
"common.loadMore": "Завантажити більше",
"common.key.esc": "ESC",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
"common.key.shift": "Shift",
"common.key.meta": "Meta",
"common.key.space": "Пробіл",
"common.key.backspace": "Backspace",
"common.key.enter": "Enter",
"common.key.tab": "Tab",
"common.key.delete": "Delete",
"common.key.home": "Home",
"common.key.end": "End",
"common.key.pageUp": "Page Up",
"common.key.pageDown": "Page Down",
"common.key.insert": "Insert",
"common.unknown": "невідомо",
"common.time.justNow": "Щойно",
"common.time.minutesAgo.short": "{{count}} хв тому",
"common.time.hoursAgo.short": "{{count}} год тому",
"common.time.daysAgo.short": "{{count}} дн тому",
"sidebar.menu.toggle": "Перемкнути меню",
"sidebar.nav.projectsAndSessions": "Проєкти та сесії",
"sidebar.settings": "Налаштування",
"sidebar.help": "Довідка",
"sidebar.workspaces.enable": "Увімкнути робочі області",
"sidebar.workspaces.disable": "Вимкнути робочі області",
"sidebar.gettingStarted.title": "Початок роботи",
"sidebar.gettingStarted.line1": "OpenCode містить безкоштовні моделі, тому ви можете почати негайно.",
"sidebar.gettingStarted.line2":
"Підключіть будь-якого провайдера, щоб використовувати моделі, включаючи Claude, GPT, Gemini тощо.",
"sidebar.project.recentSessions": "Нещодавні сесії",
"sidebar.project.viewAllSessions": "Переглянути всі сесії",
"sidebar.project.clearNotifications": "Очистити сповіщення",
"sidebar.empty.title": "Немає відкритих проєктів",
"sidebar.empty.description": "Відкрийте проєкт, щоб почати",
"debugBar.ariaLabel": "Діагностика продуктивності розробки",
"debugBar.na": "н/д",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Останній завершений перехід маршруту, що торкається сторінки сесії, виміряний від запуску маршрутизатора до першого відображення після стабілізації.",
"debugBar.fps.label": "FPS",
"debugBar.fps.tip": "Поточна кількість кадрів за секунду за останні 5 секунд.",
"debugBar.frame.label": "FRAME",
"debugBar.frame.tip": "Найгірший час кадру за останні 5 секунд.",
"debugBar.jank.label": "JANK",
"debugBar.jank.tip": "Кадри понад 32 мс за останні 5 секунд.",
"debugBar.long.label": "LONG",
"debugBar.long.tip": "Заблокований час і кількість довгих завдань за останні 5 секунд. Макс. завдання: {{max}}.",
"debugBar.delay.label": "DELAY",
"debugBar.delay.tip": "Найгірша спостережувана затримка введення за останні 5 секунд.",
"debugBar.inp.label": "INP",
"debugBar.inp.tip":
"Приблизна тривалість взаємодії за останні 5 секунд. Це схоже на INP, а не на офіційний Web Vitals INP.",
"debugBar.cls.label": "CLS",
"debugBar.cls.tip": "Сукупний зсув макета за весь час роботи програми.",
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "Використана купа JS проти ліміту купи. Тільки Chromium.",
"debugBar.mem.tip": "Використана купа JS проти ліміту купи. {{used}} з {{limit}}.",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Робочий стіл",
"settings.section.server": "Сервер",
"settings.tab.general": "Загальні",
"settings.tab.shortcuts": "Скорочення",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "Інтеграція WSL",
"settings.desktop.wsl.description": "Запускати сервер OpenCode всередині WSL на Windows.",
"settings.general.section.appearance": "Зовнішній вигляд",
"settings.general.section.advanced": "Додатково",
"settings.general.section.notifications": "Системні сповіщення",
"settings.general.section.updates": "Оновлення",
"settings.general.section.sounds": "Звукові ефекти",
"settings.general.section.feed": "Стрічка",
"settings.general.section.display": "Дисплей",
"settings.general.row.language.title": "Мова",
"settings.general.row.language.description": "Змінити мову інтерфейсу OpenCode",
"settings.general.row.shell.title": "Командна оболонка термінала",
"settings.general.row.shell.description":
"Виберіть оболонку для термінала. Сумісні оболонки також використовуються для викликів інструментів агента.",
"settings.general.row.shell.autoDefault": "Авто (за замовчуванням)",
"settings.general.row.shell.terminalOnly": "тільки термінал",
"settings.general.row.appearance.title": "Зовнішній вигляд",
"settings.general.row.appearance.description": "Налаштуйте вигляд OpenCode на вашому пристрої",
"settings.general.row.colorScheme.title": "Кольорова схема",
"settings.general.row.colorScheme.description": "Виберіть, чи OpenCode використовує системну, світлу або темну тему",
"settings.general.row.theme.title": "Тема",
"settings.general.row.theme.description": "Налаштуйте тему OpenCode.",
"settings.general.row.font.title": "Шрифт коду",
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
"settings.general.row.terminalFont.title": "Шрифт термінала",
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
"settings.general.row.followup.title": "Поведінка продовження",
"settings.general.row.followup.description": "Виберіть, чи продовження виконується негайно, чи чекає в черзі",
"settings.general.row.followup.option.queue": "Черга",
"settings.general.row.followup.option.steer": "Керування",
"settings.general.row.showFileTree.title": "Дерево файлів",
"settings.general.row.showFileTree.description": "Показувати панель дерева файлів у сесіях на робочому столі",
"settings.general.row.showNavigation.title": "Елементи навігації",
"settings.general.row.showNavigation.description": "Показувати кнопки назад і вперед у заголовку робочого столу",
"settings.general.row.showSearch.title": "Палітра команд",
"settings.general.row.showSearch.description":
"Показувати кнопку пошуку та палітри команд у заголовку робочого столу",
"settings.general.row.showTerminal.title": "Термінал",
"settings.general.row.showTerminal.description": "Показувати кнопку термінала в заголовку робочого столу",
"settings.general.row.showStatus.title": "Статус сервера",
"settings.general.row.showStatus.description": "Показувати кнопку статусу сервера в заголовку робочого столу",
"settings.general.row.reasoningSummaries.title": "Показувати підсумки мислення",
"settings.general.row.reasoningSummaries.description": "Відображати підсумки мислення моделі на часовій шкалі",
"settings.general.row.shellToolPartsExpanded.title": "Розгортати частини інструменту оболонки",
"settings.general.row.shellToolPartsExpanded.description":
"Показувати частини інструменту оболонки розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.editToolPartsExpanded.title": "Розгортати частини інструменту редагування",
"settings.general.row.editToolPartsExpanded.description":
"Показувати частини інструментів редагування, запису та патчів розгорнутими за замовчуванням на часовій шкалі",
"settings.general.row.showSessionProgressBar.title": "Показувати індикатор прогресу сесії",
"settings.general.row.showSessionProgressBar.description":
"Відображати анімований індикатор прогресу вгорі сесії, коли агент працює",
"settings.general.row.wayland.title": "Використовувати нативний Wayland",
"settings.general.row.wayland.description": "Вимкнути резервний X11 на Wayland. Потребує перезапуску.",
"settings.general.row.wayland.tooltip":
"На Linux з моніторами з різною частотою оновлення нативний Wayland може бути більш стабільним.",
"settings.general.row.releaseNotes.title": "Нотатки до релізу",
"settings.general.row.releaseNotes.description": 'Показувати спливаючі вікна "Що нового" після оновлень',
"settings.updates.row.startup.title": "Перевіряти оновлення під час запуску",
"settings.updates.row.startup.description": "Автоматично перевіряти наявність оновлень під час запуску OpenCode",
"settings.updates.row.check.title": "Перевірити оновлення",
"settings.updates.row.check.description": "Вручну перевірити наявність оновлень і встановити, якщо доступні",
"settings.updates.action.checkNow": "Перевірити зараз",
"settings.updates.action.checking": "Перевірка...",
"settings.updates.toast.latest.title": "У вас актуальна версія",
"settings.updates.toast.latest.description": "Ви використовуєте останню версію OpenCode.",
"sound.option.none": "Немає",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",
"sound.option.alert04": "Alert 04",
"sound.option.alert05": "Alert 05",
"sound.option.alert06": "Alert 06",
"sound.option.alert07": "Alert 07",
"sound.option.alert08": "Alert 08",
"sound.option.alert09": "Alert 09",
"sound.option.alert10": "Alert 10",
"sound.option.bipbop01": "Bip-bop 01",
"sound.option.bipbop02": "Bip-bop 02",
"sound.option.bipbop03": "Bip-bop 03",
"sound.option.bipbop04": "Bip-bop 04",
"sound.option.bipbop05": "Bip-bop 05",
"sound.option.bipbop06": "Bip-bop 06",
"sound.option.bipbop07": "Bip-bop 07",
"sound.option.bipbop08": "Bip-bop 08",
"sound.option.bipbop09": "Bip-bop 09",
"sound.option.bipbop10": "Bip-bop 10",
"sound.option.staplebops01": "Staplebops 01",
"sound.option.staplebops02": "Staplebops 02",
"sound.option.staplebops03": "Staplebops 03",
"sound.option.staplebops04": "Staplebops 04",
"sound.option.staplebops05": "Staplebops 05",
"sound.option.staplebops06": "Staplebops 06",
"sound.option.staplebops07": "Staplebops 07",
"sound.option.nope01": "Nope 01",
"sound.option.nope02": "Nope 02",
"sound.option.nope03": "Nope 03",
"sound.option.nope04": "Nope 04",
"sound.option.nope05": "Nope 05",
"sound.option.nope06": "Nope 06",
"sound.option.nope07": "Nope 07",
"sound.option.nope08": "Nope 08",
"sound.option.nope09": "Nope 09",
"sound.option.nope10": "Nope 10",
"sound.option.nope11": "Nope 11",
"sound.option.nope12": "Nope 12",
"sound.option.yup01": "Yup 01",
"sound.option.yup02": "Yup 02",
"sound.option.yup03": "Yup 03",
"sound.option.yup04": "Yup 04",
"sound.option.yup05": "Yup 05",
"sound.option.yup06": "Yup 06",
"settings.general.notifications.agent.title": "Агент",
"settings.general.notifications.agent.description":
"Показувати системне сповіщення, коли агент завершує роботу або потребує уваги",
"settings.general.notifications.permissions.title": "Дозволи",
"settings.general.notifications.permissions.description": "Показувати системне сповіщення, коли потрібен дозвіл",
"settings.general.notifications.errors.title": "Помилки",
"settings.general.notifications.errors.description": "Показувати системне сповіщення, коли виникає помилка",
"settings.general.sounds.agent.title": "Агент",
"settings.general.sounds.agent.description": "Відтворювати звук, коли агент завершує роботу або потребує уваги",
"settings.general.sounds.permissions.title": "Дозволи",
"settings.general.sounds.permissions.description": "Відтворювати звук, коли потрібен дозвіл",
"settings.general.sounds.errors.title": "Помилки",
"settings.general.sounds.errors.description": "Відтворювати звук, коли виникає помилка",
"settings.shortcuts.title": "Скорочення клавіш",
"settings.shortcuts.reset.button": "Скинути до стандартних",
"settings.shortcuts.reset.toast.title": "Скорочення скинуто",
"settings.shortcuts.reset.toast.description": "Скорочення клавіш були скинуті до стандартних.",
"settings.shortcuts.conflict.title": "Скорочення вже використовується",
"settings.shortcuts.conflict.description": "{{keybind}} вже призначено для {{titles}}.",
"settings.shortcuts.unassigned": "Не призначено",
"settings.shortcuts.pressKeys": "Натисніть клавіші",
"settings.shortcuts.search.placeholder": "Пошук скорочень",
"settings.shortcuts.search.empty": "Скорочень не знайдено",
"settings.shortcuts.group.general": "Загальні",
"settings.shortcuts.group.session": "Сесія",
"settings.shortcuts.group.navigation": "Навігація",
"settings.shortcuts.group.modelAndAgent": "Модель та агент",
"settings.shortcuts.group.terminal": "Термінал",
"settings.shortcuts.group.prompt": "Запит",
"settings.providers.title": "Провайдери",
"settings.providers.description": "Налаштування провайдерів будуть доступні тут.",
"settings.providers.section.connected": "Підключені провайдери",
"settings.providers.connected.empty": "Немає підключених провайдерів",
"settings.providers.connected.environmentDescription": "Підключено зі змінних середовища",
"settings.providers.section.popular": "Популярні провайдери",
"settings.providers.custom.description": "Додайте провайдера, сумісного з OpenAI, за базовим URL.",
"settings.providers.tag.environment": "Середовище",
"settings.providers.tag.config": "Конфігурація",
"settings.providers.tag.custom": "Користувацький",
"settings.providers.tag.other": "Інше",
"settings.models.title": "Моделі",
"settings.models.description": "Налаштування моделей будуть доступні тут.",
"settings.agents.title": "Агенти",
"settings.agents.description": "Налаштування агентів будуть доступні тут.",
"settings.commands.title": "Команди",
"settings.commands.description": "Налаштування команд будуть доступні тут.",
"settings.mcp.title": "MCP",
"settings.mcp.description": "Налаштування MCP будуть доступні тут.",
"settings.permissions.title": "Дозволи",
"settings.permissions.description": "Керуйте тим, які інструменти сервер може використовувати за замовчуванням.",
"settings.permissions.section.tools": "Інструменти",
"settings.permissions.toast.updateFailed.title": "Не вдалося оновити дозволи",
"settings.permissions.action.allow": "Дозволити",
"settings.permissions.action.ask": "Запитувати",
"settings.permissions.action.deny": "Заборонити",
"settings.permissions.tool.read.title": "Читання",
"settings.permissions.tool.read.description": "Читання файлу (відповідає шляху файлу)",
"settings.permissions.tool.edit.title": "Редагування",
"settings.permissions.tool.edit.description": "Зміна файлів, включаючи редагування, запис і патчі",
"settings.permissions.tool.glob.title": "Glob",
"settings.permissions.tool.glob.description": "Зіставлення файлів за допомогою glob-шаблонів",
"settings.permissions.tool.grep.title": "Grep",
"settings.permissions.tool.grep.description": "Пошук вмісту файлів за допомогою регулярних виразів",
"settings.permissions.tool.list.title": "Список",
"settings.permissions.tool.list.description": "Список файлів у каталозі",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "Запуск команд оболонки",
"settings.permissions.tool.task.title": "Завдання",
"settings.permissions.tool.task.description": "Запуск підагентів",
"settings.permissions.tool.skill.title": "Навичка",
"settings.permissions.tool.skill.description": "Завантаження навички за назвою",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "Виконання запитів мовного сервера",
"settings.permissions.tool.todowrite.title": "Todo Write",
"settings.permissions.tool.todowrite.description": "Оновлення списку завдань",
"settings.permissions.tool.webfetch.title": "Web Fetch",
"settings.permissions.tool.webfetch.description": "Отримання вмісту з URL",
"settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Пошук в інтернеті",
"settings.permissions.tool.external_directory.title": "Зовнішній каталог",
"settings.permissions.tool.external_directory.description": "Доступ до файлів за межами каталогу проєкту",
"settings.permissions.tool.doom_loop.title": "Цикл приреченості",
"settings.permissions.tool.doom_loop.description":
"Виявлення повторюваних викликів інструментів з однаковими вхідними даними",
"session.delete.failed.title": "Не вдалося видалити сесію",
"session.delete.title": "Видалити сесію",
"session.delete.confirm": 'Видалити сесію "{{name}}"?',
"session.delete.button": "Видалити сесію",
"workspace.new": "Нова робоча область",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",
"workspace.delete.failed.title": "Не вдалося видалити робочу область",
"workspace.resetting.title": "Скидання робочої області",
"workspace.resetting.description": "Це може зайняти хвилину.",
"workspace.reset.failed.title": "Не вдалося скинути робочу область",
"workspace.reset.success.title": "Робочу область скинуто",
"workspace.reset.success.description": "Робоча область тепер відповідає гілці за замовчуванням.",
"workspace.error.stillPreparing": "Робоча область все ще готується",
"workspace.status.checking": "Перевірка незлитих змін...",
"workspace.status.error": "Не вдалося перевірити статус git.",
"workspace.status.clean": "Незлитих змін не виявлено.",
"workspace.status.dirty": "Виявлено незлиті зміни в цій робочій області.",
"workspace.delete.title": "Видалити робочу область",
"workspace.delete.confirm": 'Видалити робочу область "{{name}}"?',
"workspace.delete.button": "Видалити робочу область",
"workspace.reset.title": "Скинути робочу область",
"workspace.reset.confirm": 'Скинути робочу область "{{name}}"?',
"workspace.reset.button": "Скинути робочу область",
"workspace.reset.archived.none": "Жодна активна сесія не буде заархівована.",
"workspace.reset.archived.one": "1 сесію буде заархівовано.",
"workspace.reset.archived.many": "{{count}} сесій буде заархівовано.",
"workspace.reset.note": "Це скине робочу область, щоб вона відповідала гілці за замовчуванням.",
}
+3 -11
View File
@@ -462,7 +462,7 @@ export const dict = {
"error.page.report.discord": "在 Discord 上",
"error.page.version": "版本:{{version}}",
"error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
"error.serverSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
"directory.error.invalidUrl": "URL 中的目录无效。",
@@ -498,14 +498,6 @@ export const dict = {
"home.recentProjects": "最近项目",
"home.empty.title": "没有最近项目",
"home.empty.description": "通过打开本地项目开始使用",
"home.title": "主页",
"home.projects": "项目",
"home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话",
"home.sessions.empty": "未找到会话",
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"session.tab.session": "会话",
"session.tab.review": "审查",
@@ -919,8 +911,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循环]",
"error.serverSDK.noServerAvailable": "无可用服务器",
"error.serverSDK.serverNotAvailable": "服务器不可用",
"error.globalSDK.noServerAvailable": "无可用服务器",
"error.globalSDK.serverNotAvailable": "服务器不可用",
"error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败",
"error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败",
"error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败",
+3 -3
View File
@@ -457,7 +457,7 @@ export const dict = {
"error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
"error.serverSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
"directory.error.invalidUrl": "URL 中的目錄無效。",
"error.chain.unknown": "未知錯誤",
@@ -907,8 +907,8 @@ export const dict = {
"common.key.insert": "Insert",
"common.unknown": "未知",
"error.page.circular": "[循環]",
"error.serverSDK.noServerAvailable": "無可用的伺服器",
"error.serverSDK.serverNotAvailable": "伺服器無法使用",
"error.globalSDK.noServerAvailable": "無可用的伺服器",
"error.globalSDK.serverNotAvailable": "伺服器無法使用",
"error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗",
"error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗",
"error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗",
+2 -65
View File
@@ -1,5 +1,4 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";
@@ -8,13 +7,6 @@
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/assets/Inter.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@layer components {
@keyframes session-progress-whip {
0% {
@@ -50,7 +42,9 @@
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path;
}
@@ -59,63 +53,6 @@
container-name: getting-started;
}
[data-component="dropdown-menu-content"].desktop-app-menu,
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
min-width: 160px;
padding: 2px;
}
[data-component="dropdown-menu-content"].desktop-app-menu {
width: 160px;
}
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
width: max-content;
min-width: 240px;
max-width: min(320px, calc(100vw - 24px));
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--text-weak);
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
min-height: 28px;
padding: 0 12px;
gap: 8px;
font-weight: var(--font-weight-regular);
line-height: 1;
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
white-space: nowrap;
}
[data-slot="desktop-app-menu-keybind"] {
margin-left: auto;
color: var(--text-weak);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-regular);
white-space: nowrap;
}
[data-slot="desktop-app-menu-chevron"] {
display: flex;
margin-left: auto;
color: var(--icon-base);
}
[data-component="getting-started-actions"] {
display: flex;
flex-direction: column;

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